Просмотр исходного кода

Merge branch 'develop' into AjayTripathy-fix-receive-transfer

Ajay Tripathy 3 лет назад
Родитель
Сommit
a673bb4905

+ 1 - 0
.gitignore

@@ -2,6 +2,7 @@
 .idea
 *.iml
 
+ui/.parcel-cache
 ui/.cache
 ui/dist
 ui/node_modules/

+ 6 - 6
configs/aws.json

@@ -12,14 +12,14 @@
     "internetNetworkEgress": "0.143",
     "spotLabel": "kops.k8s.io/instancegroup",
     "spotLabelValue": "spotinstance-nodes",
-    "awsServiceKeyName": "AKIAXW6UVLRRY5RQGGUX",
+    "awsServiceKeyName": "AKIXXX",
     "awsServiceKeySecret": "",
     "awsSpotDataRegion":"us-east-2",
-    "awsSpotDataBucket": "kc-test-spot",
+    "awsSpotDataBucket": "x",
     "awsSpotDataPrefix": "spotdata",
-    "athenaBucketName": "s3://aws-athena-query-results-530337586275-us-east-1",
+    "athenaBucketName": "s3://x",
     "athenaRegion": "us-east-1",
-    "athenaDatabase": "athenacurcfn_athena_test",
-    "athenaTable": "athena_test",
-    "projectID": "530337586275"
+    "athenaDatabase": "",
+    "athenaTable": "",
+    "projectID": "12345"
 }

+ 1 - 1
configs/gcp.json

@@ -5,7 +5,7 @@
     "spotCPU": "0.006655",
     "RAM": "0.004237",
     "spotRAM": "0.000892",
-    "projectID": "guestbook-227502",
+    "projectID": "todo",
     "storage": "0.00005479452",
     "zoneNetworkEgress": "0.01",
     "regionNetworkEgress": "0.01",

+ 3 - 1
docs/README.md

@@ -1 +1,3 @@
-The docs are available at <https://www.opencost.io/docs/> and the source is at <https://github.com/opencost/opencost-website/>
+The docs are available at <https://www.opencost.io/docs/> with additional source at <https://github.com/opencost/opencost-website/>.
+
+All documentation in this repository is made available by the CNCF under the [Creative Commons Attribution 4.0 International License](https://creativecommons.org/licenses/by/4.0/).

+ 16 - 1
pkg/cloud/aliyunprovider.go

@@ -620,6 +620,14 @@ func (alibaba *Alibaba) loadAlibabaAuthSecretAndSetEnv(force bool) error {
 
 // Regions returns a current supported list of Alibaba regions
 func (alibaba *Alibaba) Regions() []string {
+
+	regionOverrides := env.GetRegionOverrideList()
+
+	if len(regionOverrides) > 0 {
+		log.Debugf("Overriding Alibaba regions with configured region list: %+v", regionOverrides)
+		return regionOverrides
+	}
+
 	return alibabaRegions
 }
 
@@ -1323,7 +1331,14 @@ func determinePVRegion(pv *v1.PersistentVolume) string {
 		}
 	}
 
-	for _, region := range alibabaRegions {
+	regionOverrides := env.GetRegionOverrideList()
+	regions := alibabaRegions
+
+	if len(regionOverrides) > 0 {
+		regions = regionOverrides
+	}
+
+	for _, region := range regions {
 		if strings.Contains(pvZone, region) {
 			log.Debugf("determinePVRegion determined region of %s through zone affiliation of the PV %s\n", region, pvZone)
 			return region

+ 20 - 8
pkg/cloud/awsprovider.go

@@ -1484,14 +1484,16 @@ func (aws *AWS) getAddressesForRegion(ctx context.Context, region string) (*ec2.
 func (aws *AWS) getAllAddresses() ([]*ec2Types.Address, error) {
 	aws.ConfigureAuth() // load authentication data into env vars
 
-	addressCh := make(chan *ec2.DescribeAddressesOutput, len(awsRegions))
-	errorCh := make(chan error, len(awsRegions))
+	regions := aws.Regions()
+
+	addressCh := make(chan *ec2.DescribeAddressesOutput, len(regions))
+	errorCh := make(chan error, len(regions))
 
 	var wg sync.WaitGroup
-	wg.Add(len(awsRegions))
+	wg.Add(len(regions))
 
 	// Get volumes from each AWS region
-	for _, r := range awsRegions {
+	for _, r := range regions {
 		// Fetch IP address response and send results and errors to their
 		// respective channels
 		go func(region string) {
@@ -1584,14 +1586,16 @@ func (aws *AWS) getDisksForRegion(ctx context.Context, region string, maxResults
 func (aws *AWS) getAllDisks() ([]*ec2Types.Volume, error) {
 	aws.ConfigureAuth() // load authentication data into env vars
 
-	volumeCh := make(chan *ec2.DescribeVolumesOutput, len(awsRegions))
-	errorCh := make(chan error, len(awsRegions))
+	regions := aws.Regions()
+
+	volumeCh := make(chan *ec2.DescribeVolumesOutput, len(regions))
+	errorCh := make(chan error, len(regions))
 
 	var wg sync.WaitGroup
-	wg.Add(len(awsRegions))
+	wg.Add(len(regions))
 
 	// Get volumes from each AWS region
-	for _, r := range awsRegions {
+	for _, r := range regions {
 		// Fetch volume response and send results and errors to their
 		// respective channels
 		go func(region string) {
@@ -2297,6 +2301,14 @@ func (aws *AWS) CombinedDiscountForNode(instanceType string, isPreemptible bool,
 
 // Regions returns a predefined list of AWS regions
 func (aws *AWS) Regions() []string {
+
+	regionOverrides := env.GetRegionOverrideList()
+
+	if len(regionOverrides) > 0 {
+		log.Debugf("Overriding AWS regions with configured region list: %+v", regionOverrides)
+		return regionOverrides
+	}
+
 	return awsRegions
 }
 

+ 8 - 0
pkg/cloud/azureprovider.go

@@ -1507,6 +1507,14 @@ func (az *Azure) CombinedDiscountForNode(instanceType string, isPreemptible bool
 }
 
 func (az *Azure) Regions() []string {
+
+	regionOverrides := env.GetRegionOverrideList()
+
+	if len(regionOverrides) > 0 {
+		log.Debugf("Overriding Azure regions with configured region list: %+v", regionOverrides)
+		return regionOverrides
+	}
+
 	return azureRegions
 }
 

+ 8 - 0
pkg/cloud/gcpprovider.go

@@ -1538,6 +1538,14 @@ func (gcp *GCP) CombinedDiscountForNode(instanceType string, isPreemptible bool,
 }
 
 func (gcp *GCP) Regions() []string {
+
+	regionOverrides := env.GetRegionOverrideList()
+
+	if len(regionOverrides) > 0 {
+		log.Debugf("Overriding GCP regions with configured region list: %+v", regionOverrides)
+		return regionOverrides
+	}
+
 	return gcpRegions
 }
 

+ 24 - 9
pkg/cloud/providerconfig.go

@@ -211,16 +211,31 @@ func (pc *ProviderConfig) UpdateFromMap(a map[string]string) (*CustomPricing, er
 
 // DefaultPricing should be returned so we can do computation even if no file is supplied.
 func DefaultPricing() *CustomPricing {
+	// https://cloud.google.com/compute/all-pricing
 	return &CustomPricing{
-		Provider:              "base",
-		Description:           "Default prices based on GCP us-central1",
-		CPU:                   "0.031611",
-		SpotCPU:               "0.006655",
-		RAM:                   "0.004237",
-		SpotRAM:               "0.000892",
-		GPU:                   "0.95",
-		SpotGPU:               "0.308",
-		Storage:               "0.00005479452",
+		Provider:    "base",
+		Description: "Default prices based on GCP us-central1",
+
+		// E2 machine types in GCP us-central1 (Iowa)
+		CPU:     "0.021811", // per vCPU hour
+		SpotCPU: "0.006543", // per vCPU hour
+		RAM:     "0.002923", // per G(i?)B hour
+		SpotRAM: "0.000877", // per G(i?)B hour
+
+		// There are many GPU types. This serves as a reasonably-appropriate
+		// estimate within a broad range (0.35 up to 3.93)
+		GPU: "0.95", // per GPU hour
+		// Same story as above.
+		SpotGPU: "0.308", // per GPU hour
+
+		// This is the "Standard provision space" pricing in the "Disk pricing"
+		// table.
+		//
+		// (($.04 / month) per G(i?)B) *
+		//   month/730 hours =
+		//     0.00005479452054794521
+		Storage: "0.00005479452",
+
 		ZoneNetworkEgress:     "0.01",
 		RegionNetworkEgress:   "0.01",
 		InternetNetworkEgress: "0.12",

+ 10 - 1
pkg/cloud/scalewayprovider.go

@@ -3,13 +3,14 @@ package cloud
 import (
 	"errors"
 	"fmt"
-	"github.com/opencost/opencost/pkg/kubecost"
 	"io"
 	"strconv"
 	"strings"
 	"sync"
 	"time"
 
+	"github.com/opencost/opencost/pkg/kubecost"
+
 	"github.com/opencost/opencost/pkg/clustercache"
 	"github.com/opencost/opencost/pkg/env"
 	"github.com/opencost/opencost/pkg/util"
@@ -241,6 +242,14 @@ func (c *Scaleway) CombinedDiscountForNode(instanceType string, isPreemptible bo
 }
 
 func (c *Scaleway) Regions() []string {
+
+	regionOverrides := env.GetRegionOverrideList()
+
+	if len(regionOverrides) > 0 {
+		log.Debugf("Overriding Scaleway regions with configured region list: %+v", regionOverrides)
+		return regionOverrides
+	}
+
 	// These are zones but hey, its 2022
 	zones := []string{}
 	for _, zone := range scw.AllZones {

+ 16 - 18
pkg/costmodel/aggregation.go

@@ -1076,7 +1076,7 @@ func (a *Accesses) ComputeAggregateCostModel(promClient prometheusClient.Client,
 		if durMins%60 != 0 || durMins < 3*60 { // not divisible by 1h or less than 3h
 			resolution = time.Minute
 		}
-	} else { // greater than 1d
+	} else {                    // greater than 1d
 		if durMins >= 7*24*60 { // greater than (or equal to) 7 days
 			resolution = 24.0 * time.Hour
 		} else if durMins >= 2*24*60 { // greater than (or equal to) 2 days
@@ -2191,12 +2191,11 @@ func (a *Accesses) ComputeAllocationHandlerSummary(w http.ResponseWriter, r *htt
 
 	// Accumulate, if requested
 	if accumulate {
-		as, err := asr.Accumulate()
+		asr, err = asr.Accumulate(kubecost.AccumulateOptionAll)
 		if err != nil {
 			WriteError(w, InternalServerError(err.Error()))
 			return
 		}
-		asr = kubecost.NewAllocationSetRange(as)
 	}
 
 	sasl := []*kubecost.SummaryAllocationSet{}
@@ -2244,10 +2243,15 @@ func (a *Accesses) ComputeAllocationHandler(w http.ResponseWriter, r *http.Reque
 	// sums each Set in the Range, producing one Set.
 	accumulate := qp.GetBool("accumulate", false)
 
-	// AccumulateBy is an optional parameter that accumulates an AllocationSetRange
+	// Accumulate is an optional parameter that accumulates an AllocationSetRange
 	// by the resolution of the given time duration.
 	// Defaults to 0. If a value is not passed then the parameter is not used.
-	accumulateBy := qp.GetDuration("accumulateBy", 0)
+	accumulateBy := kubecost.AccumulateOption(qp.Get("accumulateBy", ""))
+
+	// if accumulateBy is not explicitly set, and accumulate is true, ensure result is accumulated
+	if accumulateBy == kubecost.AccumulateOptionNone && accumulate {
+		accumulateBy = kubecost.AccumulateOptionAll
+	}
 
 	// Query for AllocationSets in increments of the given step duration,
 	// appending each to the AllocationSetRange.
@@ -2277,19 +2281,13 @@ func (a *Accesses) ComputeAllocationHandler(w http.ResponseWriter, r *http.Reque
 	}
 
 	// Accumulate, if requested
-	if accumulateBy != 0 {
-		asr, err = asr.AccumulateBy(accumulateBy)
-		if err != nil {
-			WriteError(w, InternalServerError(err.Error()))
-			return
-		}
-	} else if accumulate {
-		as, err := asr.Accumulate()
-		if err != nil {
-			WriteError(w, InternalServerError(err.Error()))
-			return
-		}
-		asr = kubecost.NewAllocationSetRange(as)
+	if accumulateBy != kubecost.AccumulateOptionNone {
+		asr, err = asr.Accumulate(accumulateBy)
+	}
+
+	if err != nil {
+		WriteError(w, InternalServerError(err.Error()))
+		return
 	}
 
 	w.Write(WrapData(asr, nil))

+ 8 - 1
pkg/costmodel/allocation.go

@@ -174,10 +174,17 @@ func (cm *CostModel) ComputeAllocation(start, end time.Time, resolution time.Dur
 	// Accumulate to yield the result AllocationSet. After this step, we will
 	// be nearly complete, but without the raw allocation data, which must be
 	// recomputed.
-	result, err := asr.Accumulate()
+	resultASR, err := asr.Accumulate(kubecost.AccumulateOptionAll)
 	if err != nil {
 		return kubecost.NewAllocationSet(start, end), fmt.Errorf("error accumulating data for %s: %s", kubecost.NewClosedWindow(s, e), err)
 	}
+	if resultASR != nil && len(resultASR.Allocations) == 0 {
+		return kubecost.NewAllocationSet(start, end), nil
+	}
+	if length := len(resultASR.Allocations); length != 1 {
+		return kubecost.NewAllocationSet(start, end), fmt.Errorf("expected 1 accumulated allocation set, found %d sets", length)
+	}
+	result := resultASR.Allocations[0]
 
 	// Apply the annotations, labels, and services to the post-accumulation
 	// results. (See above for why this is necessary.)

+ 31 - 9
pkg/costmodel/costmodel.go

@@ -152,10 +152,22 @@ const (
 	) by (namespace,container_name,pod_name,node,%s)`
 	queryRAMUsageStr = `sort_desc(
 		avg(
-			label_replace(count_over_time(container_memory_working_set_bytes{container_name!="",container_name!="POD", instance!=""}[%s] %s), "node", "$1", "instance","(.+)")
+			label_replace(
+				label_replace(
+					label_replace(
+						count_over_time(container_memory_working_set_bytes{container!="", container!="POD", instance!=""}[%s] %s), "node", "$1", "instance", "(.+)"
+					), "container_name", "$1", "container", "(.+)"
+				), "pod_name", "$1", "pod", "(.+)"
+			)
 			*
-			label_replace(avg_over_time(container_memory_working_set_bytes{container_name!="",container_name!="POD", instance!=""}[%s] %s), "node", "$1", "instance","(.+)")
-		) by (namespace,container_name,pod_name,node,%s)
+			label_replace(
+				label_replace(
+					label_replace(
+						avg_over_time(container_memory_working_set_bytes{container!="", container!="POD", instance!=""}[%s] %s), "node", "$1", "instance", "(.+)"
+					), "container_name", "$1", "container", "(.+)"
+				), "pod_name", "$1", "pod", "(.+)"
+			)
+		) by (namespace, container_name, pod_name, node, %s)
 	)`
 	queryCPURequestsStr = `avg(
 		label_replace(
@@ -170,11 +182,15 @@ const (
 	) by (namespace,container_name,pod_name,node,%s)`
 	queryCPUUsageStr = `avg(
 		label_replace(
-		rate(
-			container_cpu_usage_seconds_total{container_name!="",container_name!="POD",instance!=""}[%s] %s
-		) , "node", "$1", "instance", "(.+)"
+			label_replace(
+				label_replace(
+					rate(
+						container_cpu_usage_seconds_total{container!="", container!="POD", instance!=""}[%s] %s
+					), "node", "$1", "instance", "(.+)"
+				), "container_name", "$1", "container", "(.+)"
+			), "pod_name", "$1", "pod", "(.+)"
 		)
-	) by (namespace,container_name,pod_name,node,%s)`
+	) by (namespace, container_name, pod_name, node, %s)`
 	queryGPURequestsStr = `avg(
 		label_replace(
 			label_replace(
@@ -1107,12 +1123,15 @@ func (cm *CostModel) GetNodeCost(cp costAnalyzerCloud.Provider) (map[string]*cos
 					log.Errorf("Could not parse total node price")
 					return nil, err
 				}
-			} else {
+			} else if newCnode.VCPUCost != "" {
 				nodePrice, err = strconv.ParseFloat(newCnode.VCPUCost, 64) // all the price was allocated to the CPU
 				if err != nil {
 					log.Errorf("Could not parse node vcpu price")
 					return nil, err
 				}
+			} else { // add case to use default pricing model when API data fails.
+				log.Debugf("No node price or CPUprice found, falling back to default")
+				nodePrice = defaultCPU*cpu + defaultRAM*ram + gpuc*defaultGPU
 			}
 			if math.IsNaN(nodePrice) {
 				log.Warnf("nodePrice parsed as NaN. Setting to 0.")
@@ -1189,12 +1208,15 @@ func (cm *CostModel) GetNodeCost(cp costAnalyzerCloud.Provider) (map[string]*cos
 					}
 					nodePrice = nodePrice - gpuPrice // remove the gpuPrice from the total, we're just costing out RAM and CPU.
 				}
-			} else {
+			} else if newCnode.VCPUCost != "" {
 				nodePrice, err = strconv.ParseFloat(newCnode.VCPUCost, 64) // all the price was allocated to the CPU
 				if err != nil {
 					log.Warnf("Could not parse node vcpu price")
 					return nil, err
 				}
+			} else { // add case to use default pricing model when API data fails.
+				log.Debugf("No node price or CPUprice found, falling back to default")
+				nodePrice = defaultCPU*cpu + defaultRAM*ram
 			}
 			if math.IsNaN(nodePrice) {
 				log.Warnf("nodePrice parsed as NaN. Setting to 0.")

+ 12 - 0
pkg/env/costmodelenv.go

@@ -95,6 +95,8 @@ const (
 
 	AllocationNodeLabelsEnabled     = "ALLOCATION_NODE_LABELS_ENABLED"
 	AllocationNodeLabelsIncludeList = "ALLOCATION_NODE_LABELS_INCLUDE_LIST"
+
+	regionOverrideList = "REGION_OVERRIDE_LIST"
 )
 
 var offsetRegex = regexp.MustCompile(`^(\+|-)(\d\d):(\d\d)$`)
@@ -535,3 +537,13 @@ func GetAllocationNodeLabelsIncludeList() []string {
 
 	return list
 }
+
+func GetRegionOverrideList() []string {
+	regionList := GetList(regionOverrideList, ",")
+
+	if regionList == nil {
+		return []string{}
+	}
+
+	return regionList
+}

+ 5 - 0
pkg/env/env.go

@@ -14,6 +14,11 @@ import (
 // envMap contains Getter and Setter implementations for environment variables
 type envMap struct{}
 
+func (em *envMap) Has(key string) bool {
+	_, ok := os.LookupEnv(key)
+	return ok
+}
+
 // Get returns the value for the provided environment variable
 func (em *envMap) Get(key string) string {
 	return os.Getenv(key)

+ 159 - 21
pkg/kubecost/allocation.go

@@ -2079,7 +2079,7 @@ func (asr *AllocationSetRange) Get(i int) (*AllocationSet, error) {
 
 // Accumulate sums each AllocationSet in the given range, returning a single cumulative
 // AllocationSet for the entire range.
-func (asr *AllocationSetRange) Accumulate() (*AllocationSet, error) {
+func (asr *AllocationSetRange) accumulate() (*AllocationSet, error) {
 	var allocSet *AllocationSet
 	var err error
 
@@ -2093,12 +2093,12 @@ func (asr *AllocationSetRange) Accumulate() (*AllocationSet, error) {
 	return allocSet, nil
 }
 
-// NewAccumulation clones the first available AllocationSet to use as the data structure to
+// newAccumulation clones the first available AllocationSet to use as the data structure to
 // Accumulate the remaining data. This leaves the original AllocationSetRange intact.
-func (asr *AllocationSetRange) NewAccumulation() (*AllocationSet, error) {
+func (asr *AllocationSetRange) newAccumulation() (*AllocationSet, error) {
 	// NOTE: Adding this API for consistency across SummaryAllocation and Assets, but this
 	// NOTE: implementation is almost identical to regular Accumulate(). The Accumulate() method
-	// NOTE: for Allocation returns Clone() of the input, which is required for AccumulateBy
+	// NOTE: for Allocation returns Clone() of the input, which is required for Accumulate
 	// NOTE: support (unit tests are great for verifying this information).
 	var allocSet *AllocationSet
 	var err error
@@ -2131,34 +2131,159 @@ func (asr *AllocationSetRange) NewAccumulation() (*AllocationSet, error) {
 	return allocSet, nil
 }
 
-// AccumulateBy sums AllocationSets based on the resolution given. The resolution given is subject to the scale used for the AllocationSets.
-// Resolutions not evenly divisible by the AllocationSetRange window durations Accumulate sets until a sum greater than or equal to the resolution is met,
-// at which point AccumulateBy will start summing from 0 until the requested resolution is met again.
-// If the requested resolution is smaller than the window of an AllocationSet then the resolution will default to the duration of a set.
-// Resolutions larger than the duration of the entire AllocationSetRange will default to the duration of the range.
-func (asr *AllocationSetRange) AccumulateBy(resolution time.Duration) (*AllocationSetRange, error) {
-	allocSetRange := NewAllocationSetRange()
-	var allocSet *AllocationSet
+// Accumulate sums AllocationSets based on the AccumulateOption (calendar week or calendar month).
+// The accumulated set is determined by the start of the window of the allocation set.
+func (asr *AllocationSetRange) Accumulate(accumulateBy AccumulateOption) (*AllocationSetRange, error) {
+	switch accumulateBy {
+	case AccumulateOptionNone:
+		return asr.accumulateByNone()
+	case AccumulateOptionAll:
+		return asr.accumulateByAll()
+	case AccumulateOptionHour:
+		return asr.accumulateByHour()
+	case AccumulateOptionDay:
+		return asr.accumulateByDay()
+	case AccumulateOptionWeek:
+		return asr.accumulateByWeek()
+	case AccumulateOptionMonth:
+		return asr.accumulateByMonth()
+	default:
+		// ideally, this should never happen
+		return nil, fmt.Errorf("unexpected error, invalid accumulateByType: %s", accumulateBy)
+	}
+
+}
+
+func (asr *AllocationSetRange) accumulateByAll() (*AllocationSetRange, error) {
 	var err error
+	var as *AllocationSet
+	as, err = asr.newAccumulation()
+	if err != nil {
+		return nil, fmt.Errorf("error accumulating all:%s", err)
+	}
+
+	accumulated := NewAllocationSetRange(as)
+	return accumulated, nil
+}
+
+func (asr *AllocationSetRange) accumulateByNone() (*AllocationSetRange, error) {
+	return asr.Clone(), nil
+}
+func (asr *AllocationSetRange) accumulateByHour() (*AllocationSetRange, error) {
+	// ensure that the summary allocation sets have a 1-hour window, if a set exists
+	if len(asr.Allocations) > 0 && asr.Allocations[0].Window.Duration() != time.Hour {
+		return nil, fmt.Errorf("window duration must equal 1 hour; got:%s", asr.Allocations[0].Window.Duration())
+	}
+
+	return asr.Clone(), nil
+}
 
+func (asr *AllocationSetRange) accumulateByDay() (*AllocationSetRange, error) {
+	// if the allocation set window is 1-day, just return the existing allocation set range
+	if len(asr.Allocations) > 0 && asr.Allocations[0].Window.Duration() == time.Hour*24 {
+		return asr, nil
+	}
+
+	var toAccumulate *AllocationSetRange
+	result := NewAllocationSetRange()
 	for i, as := range asr.Allocations {
-		allocSet, err = allocSet.Accumulate(as)
+
+		if as.Window.Duration() != time.Hour {
+			return nil, fmt.Errorf("window duration must equal 1 hour; got:%s", as.Window.Duration())
+		}
+
+		hour := as.Window.Start().Hour()
+
+		if toAccumulate == nil {
+			toAccumulate = NewAllocationSetRange()
+			as = as.Clone()
+		}
+
+		toAccumulate.Append(as)
+		asAccumulated, err := toAccumulate.accumulate()
 		if err != nil {
-			return nil, err
+			return nil, fmt.Errorf("error accumulating result: %s", err)
 		}
+		toAccumulate = NewAllocationSetRange(asAccumulated)
 
-		if allocSet != nil {
+		if hour == 23 || i == len(asr.Allocations)-1 {
+			if length := len(toAccumulate.Allocations); length != 1 {
+				return nil, fmt.Errorf("failed accumulation, detected %d sets instead of 1", length)
+			}
+			result.Append(toAccumulate.Allocations[0])
+			toAccumulate = nil
+		}
+	}
+	return result, nil
+}
 
-			// check if end of asr to sum the final set
-			// If total asr accumulated sum <= resolution return 1 accumulated set
-			if allocSet.Window.Duration() >= resolution || i == len(asr.Allocations)-1 {
-				allocSetRange.Allocations = append(allocSetRange.Allocations, allocSet)
-				allocSet = NewAllocationSet(time.Time{}, time.Time{})
+func (asr *AllocationSetRange) accumulateByMonth() (*AllocationSetRange, error) {
+	var toAccumulate *AllocationSetRange
+	result := NewAllocationSetRange()
+	for i, as := range asr.Allocations {
+		if as.Window.Duration() != time.Hour*24 {
+			return nil, fmt.Errorf("window duration must equal 24 hours; got:%s", as.Window.Duration())
+		}
+
+		_, month, _ := as.Window.Start().Date()
+		_, nextDayMonth, _ := as.Window.Start().Add(time.Hour * 24).Date()
+
+		if toAccumulate == nil {
+			toAccumulate = NewAllocationSetRange()
+			as = as.Clone()
+		}
+
+		toAccumulate.Append(as)
+		asAccumulated, err := toAccumulate.accumulate()
+		if err != nil {
+			return nil, fmt.Errorf("error accumulating result: %s", err)
+		}
+		toAccumulate = NewAllocationSetRange(asAccumulated)
+
+		// either the month has ended, or there are no more allocation sets
+		if month != nextDayMonth || i == len(asr.Allocations)-1 {
+			if length := len(toAccumulate.Allocations); length != 1 {
+				return nil, fmt.Errorf("failed accumulation, detected %d sets instead of 1", length)
 			}
+			result.Append(toAccumulate.Allocations[0])
+			toAccumulate = nil
 		}
 	}
+	return result, nil
+}
+
+func (asr *AllocationSetRange) accumulateByWeek() (*AllocationSetRange, error) {
+	var toAccumulate *AllocationSetRange
+	result := NewAllocationSetRange()
+	for i, as := range asr.Allocations {
+		if as.Window.Duration() != time.Hour*24 {
+			return nil, fmt.Errorf("window duration must equal 24 hours; got:%s", as.Window.Duration())
+		}
+
+		dayOfWeek := as.Window.Start().Weekday()
 
-	return allocSetRange, nil
+		if toAccumulate == nil {
+			toAccumulate = NewAllocationSetRange()
+			as = as.Clone()
+		}
+
+		toAccumulate.Append(as)
+		asAccumulated, err := toAccumulate.accumulate()
+		if err != nil {
+			return nil, fmt.Errorf("error accumulating result: %s", err)
+		}
+		toAccumulate = NewAllocationSetRange(asAccumulated)
+
+		// current assumption is the week always ends on Saturday, or there are no more allocation sets
+		if dayOfWeek == time.Saturday || i == len(asr.Allocations)-1 {
+			if length := len(toAccumulate.Allocations); length != 1 {
+				return nil, fmt.Errorf("failed accumulation, detected %d sets instead of 1", length)
+			}
+			result.Append(toAccumulate.Allocations[0])
+			toAccumulate = nil
+		}
+	}
+	return result, nil
 }
 
 // AggregateBy aggregates each AllocationSet in the range by the given
@@ -2435,3 +2560,16 @@ func (asr *AllocationSetRange) TotalCost() float64 {
 	}
 	return tc
 }
+
+// Clone returns a new AllocationSetRange cloned from the existing ASR
+func (asr *AllocationSetRange) Clone() *AllocationSetRange {
+	sasrClone := NewAllocationSetRange()
+	sasrClone.FromStore = asr.FromStore
+
+	for _, as := range asr.Allocations {
+		asClone := as.Clone()
+		sasrClone.Append(asClone)
+	}
+
+	return sasrClone
+}

+ 204 - 290
pkg/kubecost/allocation_test.go

@@ -1631,7 +1631,7 @@ func TestAllocationSetRange_AccumulateRepeat(t *testing.T) {
 	totalCost := asr.TotalCost()
 
 	// NewAccumulation does not mutate
-	result, err := asr.NewAccumulation()
+	result, err := asr.newAccumulation()
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -1643,7 +1643,7 @@ func TestAllocationSetRange_AccumulateRepeat(t *testing.T) {
 	}
 
 	// Next NewAccumulation() call should prove that there is no mutation of inner data
-	result, err = asr.NewAccumulation()
+	result, err = asr.newAccumulation()
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -1663,7 +1663,7 @@ func TestAllocationSetRange_Accumulate(t *testing.T) {
 	tomorrow := time.Now().UTC().Truncate(day).Add(day)
 
 	// Accumulating any combination of nil and/or empty set should result in empty set
-	result, err := NewAllocationSetRange(nil).Accumulate()
+	result, err := NewAllocationSetRange(nil).accumulate()
 	if err != nil {
 		t.Fatalf("unexpected error accumulating nil AllocationSetRange: %s", err)
 	}
@@ -1671,7 +1671,7 @@ func TestAllocationSetRange_Accumulate(t *testing.T) {
 		t.Fatalf("accumulating nil AllocationSetRange: expected empty; actual %s", result)
 	}
 
-	result, err = NewAllocationSetRange(nil, nil).Accumulate()
+	result, err = NewAllocationSetRange(nil, nil).accumulate()
 	if err != nil {
 		t.Fatalf("unexpected error accumulating nil AllocationSetRange: %s", err)
 	}
@@ -1679,7 +1679,7 @@ func TestAllocationSetRange_Accumulate(t *testing.T) {
 		t.Fatalf("accumulating nil AllocationSetRange: expected empty; actual %s", result)
 	}
 
-	result, err = NewAllocationSetRange(NewAllocationSet(yesterday, today)).Accumulate()
+	result, err = NewAllocationSetRange(NewAllocationSet(yesterday, today)).accumulate()
 	if err != nil {
 		t.Fatalf("unexpected error accumulating nil AllocationSetRange: %s", err)
 	}
@@ -1687,7 +1687,7 @@ func TestAllocationSetRange_Accumulate(t *testing.T) {
 		t.Fatalf("accumulating nil AllocationSetRange: expected empty; actual %s", result)
 	}
 
-	result, err = NewAllocationSetRange(nil, NewAllocationSet(ago2d, yesterday), nil, NewAllocationSet(today, tomorrow), nil).Accumulate()
+	result, err = NewAllocationSetRange(nil, NewAllocationSet(ago2d, yesterday), nil, NewAllocationSet(today, tomorrow), nil).accumulate()
 	if err != nil {
 		t.Fatalf("unexpected error accumulating nil AllocationSetRange: %s", err)
 	}
@@ -1702,7 +1702,7 @@ func TestAllocationSetRange_Accumulate(t *testing.T) {
 	yesterdayAS.Set(NewMockUnitAllocation("", yesterday, day, nil))
 
 	// Accumulate non-nil with nil should result in copy of non-nil, regardless of order
-	result, err = NewAllocationSetRange(nil, todayAS).Accumulate()
+	result, err = NewAllocationSetRange(nil, todayAS).accumulate()
 	if err != nil {
 		t.Fatalf("unexpected error accumulating AllocationSetRange of length 1: %s", err)
 	}
@@ -1713,7 +1713,7 @@ func TestAllocationSetRange_Accumulate(t *testing.T) {
 		t.Fatalf("accumulating AllocationSetRange: expected total cost 6.0; actual %f", result.TotalCost())
 	}
 
-	result, err = NewAllocationSetRange(todayAS, nil).Accumulate()
+	result, err = NewAllocationSetRange(todayAS, nil).accumulate()
 	if err != nil {
 		t.Fatalf("unexpected error accumulating AllocationSetRange of length 1: %s", err)
 	}
@@ -1724,7 +1724,7 @@ func TestAllocationSetRange_Accumulate(t *testing.T) {
 		t.Fatalf("accumulating AllocationSetRange: expected total cost 6.0; actual %f", result.TotalCost())
 	}
 
-	result, err = NewAllocationSetRange(nil, todayAS, nil).Accumulate()
+	result, err = NewAllocationSetRange(nil, todayAS, nil).accumulate()
 	if err != nil {
 		t.Fatalf("unexpected error accumulating AllocationSetRange of length 1: %s", err)
 	}
@@ -1736,7 +1736,7 @@ func TestAllocationSetRange_Accumulate(t *testing.T) {
 	}
 
 	// Accumulate two non-nil should result in sum of both with appropriate start, end
-	result, err = NewAllocationSetRange(yesterdayAS, todayAS).Accumulate()
+	result, err = NewAllocationSetRange(yesterdayAS, todayAS).accumulate()
 	if err != nil {
 		t.Fatalf("unexpected error accumulating AllocationSetRange of length 1: %s", err)
 	}
@@ -1806,115 +1806,110 @@ func TestAllocationSetRange_Accumulate(t *testing.T) {
 		t.Fatalf("accumulating AllocationSetRange: expected %f minutes; actual %f", 2880.0, alloc.Minutes())
 	}
 }
-func TestAllocationSetRange_AccumulateBy_Nils(t *testing.T) {
-	var err error
-	var result *AllocationSetRange
 
+func TestAllocationSetRange_AccumulateBy_None(t *testing.T) {
+	ago4d := time.Now().UTC().Truncate(day).Add(-4 * day)
+	ago3d := time.Now().UTC().Truncate(day).Add(-3 * day)
 	ago2d := time.Now().UTC().Truncate(day).Add(-2 * day)
 	yesterday := time.Now().UTC().Truncate(day).Add(-day)
 	today := time.Now().UTC().Truncate(day)
 	tomorrow := time.Now().UTC().Truncate(day).Add(day)
 
-	// Test nil & empty sets
-	nilEmptycases := []struct {
-		asr        *AllocationSetRange
-		resolution time.Duration
-
-		testId string
-	}{
-		{
-			asr:        NewAllocationSetRange(nil),
-			resolution: time.Hour * 24 * 2,
-
-			testId: "AccumulateBy_Nils Empty Test 1",
-		},
-		{
-			asr:        NewAllocationSetRange(nil, nil),
-			resolution: time.Hour * 1,
-
-			testId: "AccumulateBy_Nils Empty Test 2",
-		},
-		{
-			asr:        NewAllocationSetRange(nil, NewAllocationSet(ago2d, yesterday), nil, NewAllocationSet(today, tomorrow)),
-			resolution: time.Hour * 24 * 7,
+	ago4dAS := NewAllocationSet(ago4d, ago3d)
+	ago4dAS.Set(NewMockUnitAllocation("4", ago4d, day, nil))
+	ago3dAS := NewAllocationSet(ago3d, ago2d)
+	ago3dAS.Set(NewMockUnitAllocation("a", ago3d, day, nil))
+	ago2dAS := NewAllocationSet(ago2d, yesterday)
+	ago2dAS.Set(NewMockUnitAllocation("", ago2d, day, nil))
+	yesterdayAS := NewAllocationSet(yesterday, today)
+	yesterdayAS.Set(NewMockUnitAllocation("", yesterday, day, nil))
+	todayAS := NewAllocationSet(today, tomorrow)
+	todayAS.Set(NewMockUnitAllocation("", today, day, nil))
 
-			testId: "AccumulateBy_Nils Empty Test 3",
-		},
+	asr := NewAllocationSetRange(ago4dAS, ago3dAS, ago2dAS, yesterdayAS, todayAS)
+	asr, err := asr.Accumulate(AccumulateOptionNone)
+	if err != nil {
+		t.Fatalf("unexpected error calling accumulateBy: %s", err)
 	}
 
-	for _, c := range nilEmptycases {
-		result, err = c.asr.AccumulateBy(c.resolution)
-		for _, as := range result.Allocations {
-			if !as.IsEmpty() {
-				t.Errorf("accumulating nil AllocationSetRange: expected empty; actual %s; TestId: %s", result, c.testId)
-			}
-		}
-	}
-	if err != nil {
-		t.Errorf("unexpected error accumulating nil AllocationSetRange: %s", err)
+	if len(asr.Allocations) != 5 {
+		t.Fatalf("expected 5 allocation sets, got:%d", len(asr.Allocations))
 	}
+}
+
+func TestAllocationSetRange_AccumulateBy_All(t *testing.T) {
+	ago4d := time.Now().UTC().Truncate(day).Add(-4 * day)
+	ago3d := time.Now().UTC().Truncate(day).Add(-3 * day)
+	ago2d := time.Now().UTC().Truncate(day).Add(-2 * day)
+	yesterday := time.Now().UTC().Truncate(day).Add(-day)
+	today := time.Now().UTC().Truncate(day)
+	tomorrow := time.Now().UTC().Truncate(day).Add(day)
 
+	ago4dAS := NewAllocationSet(ago4d, ago3d)
+	ago4dAS.Set(NewMockUnitAllocation("4", ago4d, day, nil))
+	ago3dAS := NewAllocationSet(ago3d, ago2d)
+	ago3dAS.Set(NewMockUnitAllocation("a", ago3d, day, nil))
+	ago2dAS := NewAllocationSet(ago2d, yesterday)
+	ago2dAS.Set(NewMockUnitAllocation("", ago2d, day, nil))
 	yesterdayAS := NewAllocationSet(yesterday, today)
-	yesterdayAS.Set(NewMockUnitAllocation("a", yesterday, day, nil))
+	yesterdayAS.Set(NewMockUnitAllocation("", yesterday, day, nil))
 	todayAS := NewAllocationSet(today, tomorrow)
-	todayAS.Set(NewMockUnitAllocation("b", today, day, nil))
-
-	nilAndNonEmptyCases := []struct {
-		asr        *AllocationSetRange
-		resolution time.Duration
-
-		expected float64
-		testId   string
-	}{
-		{
-			asr:        NewAllocationSetRange(nil, todayAS),
-			resolution: time.Hour * 2,
+	todayAS.Set(NewMockUnitAllocation("", today, day, nil))
 
-			expected: 6.0,
-			testId:   "AccumulateBy_Nils NonEmpty Test 1",
-		},
-		{
-			asr:        NewAllocationSetRange(todayAS, nil),
-			resolution: time.Hour * 24,
+	asr := NewAllocationSetRange(ago4dAS, ago3dAS, ago2dAS, yesterdayAS, todayAS)
+	asr, err := asr.Accumulate(AccumulateOptionAll)
+	if err != nil {
+		t.Fatalf("unexpected error calling accumulateBy: %s", err)
+	}
 
-			expected: 6.0,
-			testId:   "AccumulateBy_Nils NonEmpty Test 2",
-		},
-		{
-			asr:        NewAllocationSetRange(yesterdayAS, nil, todayAS, nil),
-			resolution: time.Hour * 24 * 2,
+	if len(asr.Allocations) != 1 {
+		t.Fatalf("expected 1 allocation set, got:%d", len(asr.Allocations))
+	}
 
-			expected: 12.0,
-			testId:   "AccumulateBy_Nils NonEmpty Test 3",
-		},
+	allocMap := asr.Allocations[0].Allocations
+	alloc := allocMap["cluster1/namespace1/pod1/container1"]
+	if alloc.Minutes() != 4320.0 {
+		t.Errorf("accumulating AllocationSetRange: expected %f minutes; actual %f", 4320.0, alloc.Minutes())
 	}
+}
 
-	for _, c := range nilAndNonEmptyCases {
-		result, err = c.asr.AccumulateBy(c.resolution)
-		sumCost := 0.0
+func TestAllocationSetRange_AccumulateBy_Hour(t *testing.T) {
+	ago4h := time.Now().UTC().Truncate(time.Hour).Add(-4 * time.Hour)
+	ago3h := time.Now().UTC().Truncate(time.Hour).Add(-3 * time.Hour)
+	ago2h := time.Now().UTC().Truncate(time.Hour).Add(-2 * time.Hour)
+	ago1h := time.Now().UTC().Truncate(time.Hour).Add(-time.Hour)
+	currentHour := time.Now().UTC().Truncate(time.Hour)
+	nextHour := time.Now().UTC().Truncate(time.Hour).Add(time.Hour)
 
-		if result == nil {
-			t.Errorf("accumulating AllocationSetRange: expected AllocationSet; actual %s; TestId: %s", result, c.testId)
-		}
+	ago4hAS := NewAllocationSet(ago4h, ago3h)
+	ago4hAS.Set(NewMockUnitAllocation("4", ago4h, time.Hour, nil))
+	ago3hAS := NewAllocationSet(ago3h, ago2h)
+	ago3hAS.Set(NewMockUnitAllocation("a", ago3h, time.Hour, nil))
+	ago2hAS := NewAllocationSet(ago2h, ago1h)
+	ago2hAS.Set(NewMockUnitAllocation("", ago2h, time.Hour, nil))
+	ago1hAS := NewAllocationSet(ago1h, currentHour)
+	ago1hAS.Set(NewMockUnitAllocation("", ago1h, time.Hour, nil))
+	currentHourAS := NewAllocationSet(currentHour, nextHour)
+	currentHourAS.Set(NewMockUnitAllocation("", currentHour, time.Hour, nil))
 
-		for _, as := range result.Allocations {
-			sumCost += as.TotalCost()
-		}
+	asr := NewAllocationSetRange(ago4hAS, ago3hAS, ago2hAS, ago1hAS, currentHourAS)
+	asr, err := asr.Accumulate(AccumulateOptionNone)
+	if err != nil {
+		t.Fatalf("unexpected error calling accumulateBy: %s", err)
+	}
 
-		if sumCost != c.expected {
-			t.Errorf("accumulating AllocationSetRange: expected total cost %f; actual %f; TestId: %s", c.expected, sumCost, c.testId)
-		}
+	if len(asr.Allocations) != 5 {
+		t.Fatalf("expected 5 allocation sets, got:%d", len(asr.Allocations))
 	}
 
-	if err != nil {
-		t.Errorf("unexpected error accumulating nil AllocationSetRange: %s", err)
+	allocMap := asr.Allocations[0].Allocations
+	alloc := allocMap["4"]
+	if alloc.Minutes() != 60.0 {
+		t.Errorf("accumulating AllocationSetRange: expected %f minutes; actual %f", 60.0, alloc.Minutes())
 	}
 }
 
-func TestAllocationSetRange_AccumulateBy(t *testing.T) {
-	var err error
-	var result *AllocationSetRange
-
+func TestAllocationSetRange_AccumulateBy_Day_From_Day(t *testing.T) {
 	ago4d := time.Now().UTC().Truncate(day).Add(-4 * day)
 	ago3d := time.Now().UTC().Truncate(day).Add(-3 * day)
 	ago2d := time.Now().UTC().Truncate(day).Add(-2 * day)
@@ -1933,223 +1928,142 @@ func TestAllocationSetRange_AccumulateBy(t *testing.T) {
 	todayAS := NewAllocationSet(today, tomorrow)
 	todayAS.Set(NewMockUnitAllocation("", today, day, nil))
 
-	yesterHour := time.Now().UTC().Truncate(time.Hour).Add(-1 * time.Hour)
+	asr := NewAllocationSetRange(ago4dAS, ago3dAS, ago2dAS, yesterdayAS, todayAS)
+	asr, err := asr.Accumulate(AccumulateOptionDay)
+	if err != nil {
+		t.Fatalf("unexpected error calling accumulateBy: %s", err)
+	}
+
+	if len(asr.Allocations) != 5 {
+		t.Fatalf("expected 5 allocation sets, got:%d", len(asr.Allocations))
+	}
+	allocMap := asr.Allocations[0].Allocations
+	alloc := allocMap["4"]
+	if alloc.Minutes() != 1440.0 {
+		t.Errorf("accumulating AllocationSetRange: expected %f minutes; actual %f", 1440.0, alloc.Minutes())
+	}
+}
+
+func TestAllocationSetRange_AccumulateBy_Day_From_Hours(t *testing.T) {
+	ago4h := time.Now().UTC().Truncate(time.Hour).Add(-4 * time.Hour)
+	ago3h := time.Now().UTC().Truncate(time.Hour).Add(-3 * time.Hour)
+	ago2h := time.Now().UTC().Truncate(time.Hour).Add(-2 * time.Hour)
+	ago1h := time.Now().UTC().Truncate(time.Hour).Add(-time.Hour)
 	currentHour := time.Now().UTC().Truncate(time.Hour)
 	nextHour := time.Now().UTC().Truncate(time.Hour).Add(time.Hour)
 
-	yesterHourAS := NewAllocationSet(yesterHour, currentHour)
-	yesterHourAS.Set(NewMockUnitAllocation("123", yesterHour, time.Hour, nil))
+	ago4hAS := NewAllocationSet(ago4h, ago3h)
+	ago4hAS.Set(NewMockUnitAllocation("", ago4h, time.Hour, nil))
+	ago3hAS := NewAllocationSet(ago3h, ago2h)
+	ago3hAS.Set(NewMockUnitAllocation("", ago3h, time.Hour, nil))
+	ago2hAS := NewAllocationSet(ago2h, ago1h)
+	ago2hAS.Set(NewMockUnitAllocation("", ago2h, time.Hour, nil))
+	ago1hAS := NewAllocationSet(ago1h, currentHour)
+	ago1hAS.Set(NewMockUnitAllocation("", ago1h, time.Hour, nil))
 	currentHourAS := NewAllocationSet(currentHour, nextHour)
-	currentHourAS.Set(NewMockUnitAllocation("456", currentHour, time.Hour, nil))
-
-	sumCost := 0.0
-
-	// Test nil & empty sets
-	cases := []struct {
-		asr        *AllocationSetRange
-		resolution time.Duration
-
-		expectedCost float64
-		expectedSets int
-
-		testId string
-	}{
-		{
-			asr:        NewAllocationSetRange(yesterdayAS, todayAS),
-			resolution: time.Hour * 24 * 2,
-
-			expectedCost: 12.0,
-			expectedSets: 1,
-
-			testId: "AccumulateBy Test 1",
-		},
-		{
-			asr:        NewAllocationSetRange(ago3dAS, ago2dAS),
-			resolution: time.Hour * 24,
-
-			expectedCost: 12.0,
-			expectedSets: 2,
-
-			testId: "AccumulateBy Test 2",
-		},
-		{
-			asr:        NewAllocationSetRange(ago2dAS, yesterdayAS, todayAS),
-			resolution: time.Hour * 13,
-
-			expectedCost: 18.0,
-			expectedSets: 3,
-
-			testId: "AccumulateBy Test 3",
-		},
-		{
-			asr:        NewAllocationSetRange(ago2dAS, yesterdayAS, todayAS),
-			resolution: time.Hour * 24 * 7,
+	currentHourAS.Set(NewMockUnitAllocation("", currentHour, time.Hour, nil))
 
-			expectedCost: 18.0,
-			expectedSets: 1,
-
-			testId: "AccumulateBy Test 4",
-		},
-		{
-			asr:        NewAllocationSetRange(yesterHourAS, currentHourAS),
-			resolution: time.Hour * 2,
-
-			//Due to how mock Allocation Sets are generated, hourly sets are still 6.0 cost per set
-			expectedCost: 12.0,
-			expectedSets: 1,
-
-			testId: "AccumulateBy Test 5",
-		},
-		{
-			asr:        NewAllocationSetRange(yesterHourAS, currentHourAS),
-			resolution: time.Hour,
-
-			expectedCost: 12.0,
-			expectedSets: 2,
-
-			testId: "AccumulateBy Test 6",
-		},
-		{
-			asr:        NewAllocationSetRange(yesterHourAS, currentHourAS),
-			resolution: time.Minute * 11,
-
-			expectedCost: 12.0,
-			expectedSets: 2,
-
-			testId: "AccumulateBy Test 7",
-		},
-		{
-			asr:        NewAllocationSetRange(yesterHourAS, currentHourAS),
-			resolution: time.Hour * 3,
-
-			expectedCost: 12.0,
-			expectedSets: 1,
-
-			testId: "AccumulateBy Test 8",
-		},
-		{
-			asr:        NewAllocationSetRange(ago2dAS, yesterdayAS, todayAS),
-			resolution: time.Hour * 24 * 2,
-
-			expectedCost: 18.0,
-			expectedSets: 2,
+	asr := NewAllocationSetRange(ago4hAS, ago3hAS, ago2hAS, ago1hAS, currentHourAS)
+	asr, err := asr.Accumulate(AccumulateOptionDay)
+	if err != nil {
+		t.Fatalf("unexpected error calling accumulateBy: %s", err)
+	}
 
-			testId: "AccumulateBy Test 9",
-		},
-		{
-			asr:        NewAllocationSetRange(ago3dAS, ago2dAS, yesterdayAS, todayAS),
-			resolution: time.Hour * 25,
+	if len(asr.Allocations) != 1 && len(asr.Allocations) != 2 {
+		t.Fatalf("expected 1 allocation set, got:%d", len(asr.Allocations))
+	}
 
-			expectedCost: 24.0,
-			expectedSets: 2,
+	allocMap := asr.Allocations[0].Allocations
+	alloc := allocMap["cluster1/namespace1/pod1/container1"]
+	if alloc.Minutes() > 300.0 {
+		t.Errorf("accumulating AllocationSetRange: expected %f or less minutes; actual %f", 300.0, alloc.Minutes())
+	}
+}
 
-			testId: "AccumulateBy Test 10",
-		},
-		{
-			asr:        NewAllocationSetRange(ago4dAS, ago3dAS, ago2dAS, yesterdayAS, todayAS),
-			resolution: time.Hour * 72,
+func TestAllocationSetRange_AccumulateBy_Week(t *testing.T) {
+	ago9d := time.Now().UTC().Truncate(day).Add(-9 * day)
+	ago8d := time.Now().UTC().Truncate(day).Add(-8 * day)
+	ago7d := time.Now().UTC().Truncate(day).Add(-7 * day)
+	ago6d := time.Now().UTC().Truncate(day).Add(-6 * day)
+	ago5d := time.Now().UTC().Truncate(day).Add(-5 * day)
+	ago4d := time.Now().UTC().Truncate(day).Add(-4 * day)
+	ago3d := time.Now().UTC().Truncate(day).Add(-3 * day)
+	ago2d := time.Now().UTC().Truncate(day).Add(-2 * day)
+	yesterday := time.Now().UTC().Truncate(day).Add(-day)
+	today := time.Now().UTC().Truncate(day)
+	tomorrow := time.Now().UTC().Truncate(day).Add(day)
 
-			expectedCost: 30.0,
-			expectedSets: 2,
+	ago9dAS := NewAllocationSet(ago9d, ago8d)
+	ago9dAS.Set(NewMockUnitAllocation("4", ago9d, day, nil))
+	ago8dAS := NewAllocationSet(ago8d, ago7d)
+	ago8dAS.Set(NewMockUnitAllocation("4", ago8d, day, nil))
+	ago7dAS := NewAllocationSet(ago7d, ago6d)
+	ago7dAS.Set(NewMockUnitAllocation("4", ago7d, day, nil))
+	ago6dAS := NewAllocationSet(ago6d, ago5d)
+	ago6dAS.Set(NewMockUnitAllocation("4", ago6d, day, nil))
+	ago5dAS := NewAllocationSet(ago5d, ago4d)
+	ago5dAS.Set(NewMockUnitAllocation("4", ago5d, day, nil))
+	ago4dAS := NewAllocationSet(ago4d, ago3d)
+	ago4dAS.Set(NewMockUnitAllocation("4", ago4d, day, nil))
+	ago3dAS := NewAllocationSet(ago3d, ago2d)
+	ago3dAS.Set(NewMockUnitAllocation("a", ago3d, day, nil))
+	ago2dAS := NewAllocationSet(ago2d, yesterday)
+	ago2dAS.Set(NewMockUnitAllocation("", ago2d, day, nil))
+	yesterdayAS := NewAllocationSet(yesterday, today)
+	yesterdayAS.Set(NewMockUnitAllocation("", yesterday, day, nil))
+	todayAS := NewAllocationSet(today, tomorrow)
+	todayAS.Set(NewMockUnitAllocation("", today, day, nil))
 
-			testId: "AccumulateBy Test 11",
-		},
+	asr := NewAllocationSetRange(ago9dAS, ago8dAS, ago7dAS, ago6dAS, ago5dAS, ago4dAS, ago3dAS, ago2dAS, yesterdayAS, todayAS)
+	asr, err := asr.Accumulate(AccumulateOptionWeek)
+	if err != nil {
+		t.Fatalf("unexpected error calling accumulateBy: %s", err)
 	}
 
-	for _, c := range cases {
-		result, err = c.asr.AccumulateBy(c.resolution)
-		sumCost := 0.0
-		if result == nil {
-			t.Errorf("accumulating AllocationSetRange: expected AllocationSet; actual %s; TestId: %s", result, c.testId)
-		}
-		if result.Length() != c.expectedSets {
-			t.Errorf("accumulating AllocationSetRange: expected %v number of allocation sets; actual %v; TestId: %s", c.expectedSets, result.Length(), c.testId)
-		}
-
-		for _, as := range result.Allocations {
-			sumCost += as.TotalCost()
-		}
-		if sumCost != c.expectedCost {
-			t.Errorf("accumulating AllocationSetRange: expected total cost %f; actual %f; TestId: %s", c.expectedCost, sumCost, c.testId)
-		}
+	if len(asr.Allocations) != 2 && len(asr.Allocations) != 3 {
+		t.Fatalf("expected 2 or 3 allocation sets, got:%d", len(asr.Allocations))
 	}
 
-	if err != nil {
-		t.Errorf("unexpected error accumulating nil AllocationSetRange: %s", err)
+	for _, as := range asr.Allocations {
+		if as.Window.Duration() < time.Hour*24 || as.Window.Duration() > time.Hour*24*7 {
+			t.Fatalf("expected window duration to be between 1 and 7 days, got:%s", as.Window.Duration().String())
+		}
 	}
+}
 
-	// // Accumulate three non-nil should result in sum of both with appropriate start, end
-	result, err = NewAllocationSetRange(ago2dAS, yesterdayAS, todayAS).AccumulateBy(time.Hour * 24 * 2)
+func TestAllocationSetRange_AccumulateBy_Month(t *testing.T) {
+	prevMonth1stDay := time.Date(2020, 01, 29, 0, 0, 0, 0, time.UTC)
+	prevMonth2ndDay := time.Date(2020, 01, 30, 0, 0, 0, 0, time.UTC)
+	prevMonth3ndDay := time.Date(2020, 01, 31, 0, 0, 0, 0, time.UTC)
+	nextMonth1stDay := time.Date(2020, 02, 01, 0, 0, 0, 0, time.UTC)
+	nextMonth2ndDay := time.Date(2020, 02, 02, 0, 0, 0, 0, time.UTC)
+
+	prev1AS := NewAllocationSet(prevMonth1stDay, prevMonth2ndDay)
+	prev1AS.Set(NewMockUnitAllocation("", prevMonth1stDay, day, nil))
+	prev2AS := NewAllocationSet(prevMonth2ndDay, prevMonth3ndDay)
+	prev2AS.Set(NewMockUnitAllocation("", prevMonth2ndDay, day, nil))
+
+	prev3AS := NewAllocationSet(prevMonth3ndDay, nextMonth1stDay)
+	prev3AS.Set(NewMockUnitAllocation("", prevMonth3ndDay, day, nil))
+
+	nextAS := NewAllocationSet(nextMonth1stDay, nextMonth2ndDay)
+	nextAS.Set(NewMockUnitAllocation("", nextMonth1stDay, day, nil))
+	// check there are two allocation sets
+	// check the windows are one month or less
+	asr := NewAllocationSetRange(prev1AS, prev2AS, prev3AS, nextAS)
+	asr, err := asr.Accumulate(AccumulateOptionMonth)
 	if err != nil {
-		t.Errorf("unexpected error accumulating AllocationSetRange of length 1: %s", err)
-	}
-	if result == nil {
-		t.Errorf("accumulating AllocationSetRange: expected AllocationSet; actual %s", result)
+		t.Fatalf("unexpected error calling accumulateBy: %s", err)
 	}
 
-	sumCost = 0.0
-	for _, as := range result.Allocations {
-		sumCost += as.TotalCost()
+	if len(asr.Allocations) != 2 {
+		t.Fatalf("expected 2 allocation sets, got:%d", len(asr.Allocations))
 	}
 
-	allocMap := result.Allocations[0].Allocations
-	if len(allocMap) != 1 {
-		t.Errorf("accumulating AllocationSetRange: expected length 1; actual length %d", len(allocMap))
-	}
-	alloc := allocMap["cluster1/namespace1/pod1/container1"]
-	if alloc == nil {
-		t.Fatalf("accumulating AllocationSetRange: expected allocation 'cluster1/namespace1/pod1/container1'")
-	}
-	if alloc.CPUCoreHours != 2.0 {
-		t.Errorf("accumulating AllocationSetRange: expected 2.0; actual %f", sumCost)
-	}
-	if alloc.CPUCost != 2.0 {
-		t.Errorf("accumulating AllocationSetRange: expected 2.0; actual %f", alloc.CPUCost)
-	}
-	if alloc.CPUEfficiency() != 1.0 {
-		t.Errorf("accumulating AllocationSetRange: expected 1.0; actual %f", alloc.CPUEfficiency())
-	}
-	if alloc.GPUHours != 2.0 {
-		t.Errorf("accumulating AllocationSetRange: expected 2.0; actual %f", alloc.GPUHours)
-	}
-	if alloc.GPUCost != 2.0 {
-		t.Errorf("accumulating AllocationSetRange: expected 2.0; actual %f", alloc.GPUCost)
-	}
-	if alloc.NetworkCost != 2.0 {
-		t.Errorf("accumulating AllocationSetRange: expected 2.0; actual %f", alloc.NetworkCost)
-	}
-	if alloc.LoadBalancerCost != 2.0 {
-		t.Errorf("accumulating AllocationSetRange: expected 2.0; actual %f", alloc.LoadBalancerCost)
-	}
-	if alloc.PVByteHours() != 2.0 {
-		t.Errorf("accumulating AllocationSetRange: expected 2.0; actual %f", alloc.PVByteHours())
-	}
-	if alloc.PVCost() != 2.0 {
-		t.Errorf("accumulating AllocationSetRange: expected 2.0; actual %f", alloc.PVCost())
-	}
-	if alloc.RAMByteHours != 2.0 {
-		t.Errorf("accumulating AllocationSetRange: expected 2.0; actual %f", alloc.RAMByteHours)
-	}
-	if alloc.RAMCost != 2.0 {
-		t.Errorf("accumulating AllocationSetRange: expected 2.0; actual %f", alloc.RAMCost)
-	}
-	if alloc.RAMEfficiency() != 1.0 {
-		t.Errorf("accumulating AllocationSetRange: expected 1.0; actual %f", alloc.RAMEfficiency())
-	}
-	if alloc.TotalCost() != 12.0 {
-		t.Errorf("accumulating AllocationSetRange: expected 12.0; actual %f", alloc.TotalCost())
-	}
-	if alloc.TotalEfficiency() != 1.0 {
-		t.Errorf("accumulating AllocationSetRange: expected 1.0; actual %f", alloc.TotalEfficiency())
-	}
-	if !alloc.Start.Equal(ago2d) {
-		t.Errorf("accumulating AllocationSetRange: expected to start %s; actual %s", ago2d, alloc.Start)
-	}
-	if !alloc.End.Equal(today) {
-		t.Errorf("accumulating AllocationSetRange: expected to end %s; actual %s", today, alloc.End)
-	}
-	if alloc.Minutes() != 2880.0 {
-		t.Errorf("accumulating AllocationSetRange: expected %f minutes; actual %f", 2880.0, alloc.Minutes())
+	for _, as := range asr.Allocations {
+		if as.Window.Duration() < time.Hour*24 || as.Window.Duration() > time.Hour*24*31 {
+			t.Fatalf("expected window duration to be between 1 and 7 days, got:%s", as.Window.Duration().String())
+		}
 	}
 }
 
@@ -2681,7 +2595,7 @@ func TestAllocationSet_Accumulate_Equals_AllocationSetRange_Accumulate(t *testin
 		asr.Append(as.Clone())
 	}
 
-	expected, err := asr.Accumulate()
+	expected, err := asr.accumulate()
 	if err != nil {
 		t.Errorf("TestAllocationSet_Accumulate_Equals_AllocationSetRange_Accumulate: AllocationSetRange.Accumulate() returned an error\n")
 	}

+ 59 - 0
pkg/kubecost/mock.go

@@ -744,3 +744,62 @@ func GenerateGCPMockCCIAndPID(mockProviderIDInt int, mockCloudIDInt int, labelKe
 		},
 	}, ""
 }
+
+// NewMockUnitSummaryAllocation creates an *SummaryAllocation with all of its float64 values set to 1 and generic properties if not provided in arg
+func NewMockUnitSummaryAllocation(name string, start time.Time, resolution time.Duration, props *AllocationProperties) *SummaryAllocation {
+	if name == "" {
+		name = "cluster1/namespace1/pod1/container1"
+	}
+
+	properties := &AllocationProperties{}
+	if props == nil {
+		properties.Cluster = "cluster1"
+		properties.Node = "node1"
+		properties.Namespace = "namespace1"
+		properties.ControllerKind = "deployment"
+		properties.Controller = "deployment1"
+		properties.Pod = "pod1"
+		properties.Container = "container1"
+	} else {
+		properties = props
+	}
+
+	end := start.Add(resolution)
+
+	alloc := &SummaryAllocation{
+		Name:                   name,
+		Properties:             properties,
+		Start:                  start,
+		End:                    end,
+		CPUCost:                1,
+		CPUCoreRequestAverage:  1,
+		CPUCoreUsageAverage:    1,
+		GPUCost:                1,
+		NetworkCost:            1,
+		LoadBalancerCost:       1,
+		RAMCost:                1,
+		RAMBytesRequestAverage: 1,
+		RAMBytesUsageAverage:   1,
+	}
+
+	// If idle allocation, remove non-idle costs, but maintain total cost
+	if alloc.IsIdle() {
+		alloc.NetworkCost = 0.0
+		alloc.LoadBalancerCost = 0.0
+		alloc.CPUCost += 1.0
+		alloc.RAMCost += 1.0
+	}
+
+	return alloc
+}
+
+// NewMockUnitSummaryAllocationSet creates an *SummaryAllocationSet
+func NewMockUnitSummaryAllocationSet(start time.Time, resolution time.Duration) *SummaryAllocationSet {
+
+	end := start.Add(resolution)
+	sas := &SummaryAllocationSet{
+		Window: NewWindow(&start, &end),
+	}
+
+	return sas
+}

+ 12 - 2
pkg/kubecost/query.go

@@ -34,8 +34,7 @@ type CloudUsageQuerier interface {
 
 // AllocationQueryOptions defines optional parameters for querying an Allocation Store
 type AllocationQueryOptions struct {
-	Accumulate              bool
-	AccumulateBy            time.Duration
+	Accumulate              AccumulateOption
 	AggregateBy             []string
 	Compute                 bool
 	DisableAggregatedStores bool
@@ -56,6 +55,17 @@ type AllocationQueryOptions struct {
 	Step                    time.Duration
 }
 
+type AccumulateOption string
+
+const (
+	AccumulateOptionNone  AccumulateOption = ""
+	AccumulateOptionAll   AccumulateOption = "all"
+	AccumulateOptionHour  AccumulateOption = "hour"
+	AccumulateOptionDay   AccumulateOption = "day"
+	AccumulateOptionWeek  AccumulateOption = "week"
+	AccumulateOptionMonth AccumulateOption = "month"
+)
+
 // AssetQueryOptions defines optional parameters for querying an Asset Store
 type AssetQueryOptions struct {
 	Accumulate              bool

+ 201 - 6
pkg/kubecost/summaryallocation.go

@@ -1303,7 +1303,7 @@ func NewSummaryAllocationSetRange(sass ...*SummaryAllocationSet) *SummaryAllocat
 
 // Accumulate sums each AllocationSet in the given range, returning a single cumulative
 // AllocationSet for the entire range.
-func (sasr *SummaryAllocationSetRange) Accumulate() (*SummaryAllocationSet, error) {
+func (sasr *SummaryAllocationSetRange) accumulate() (*SummaryAllocationSet, error) {
 	var result *SummaryAllocationSet
 	var err error
 
@@ -1320,9 +1320,9 @@ func (sasr *SummaryAllocationSetRange) Accumulate() (*SummaryAllocationSet, erro
 	return result, nil
 }
 
-// NewAccumulation clones the first available SummaryAllocationSet to use as the data structure to
+// newAccumulation clones the first available SummaryAllocationSet to use as the data structure to
 // accumulate the remaining data. This leaves the original SummaryAllocationSetRange intact.
-func (sasr *SummaryAllocationSetRange) NewAccumulation() (*SummaryAllocationSet, error) {
+func (sasr *SummaryAllocationSetRange) newAccumulation() (*SummaryAllocationSet, error) {
 	var result *SummaryAllocationSet
 	var err error
 
@@ -1374,9 +1374,6 @@ func (sasr *SummaryAllocationSetRange) AggregateBy(aggregateBy []string, options
 // Append appends the given AllocationSet to the end of the range. It does not
 // validate whether or not that violates window continuity.
 func (sasr *SummaryAllocationSetRange) Append(sas *SummaryAllocationSet) error {
-	if sasr.Step != 0 && sas.Window.Duration() != sasr.Step {
-		return fmt.Errorf("cannot append set with duration %s to range of step %s", sas.Window.Duration(), sasr.Step)
-	}
 
 	sasr.Lock()
 	defer sasr.Unlock()
@@ -1493,3 +1490,201 @@ func (sasr *SummaryAllocationSetRange) Print(verbose bool) {
 		}
 	}
 }
+
+func (sasr *SummaryAllocationSetRange) Accumulate(accumulateBy AccumulateOption) (*SummaryAllocationSetRange, error) {
+	switch accumulateBy {
+	case AccumulateOptionNone:
+		return sasr.accumulateByNone()
+	case AccumulateOptionAll:
+		return sasr.accumulateByAll()
+	case AccumulateOptionHour:
+		return sasr.accumulateByHour()
+	case AccumulateOptionDay:
+		return sasr.accumulateByDay()
+	case AccumulateOptionWeek:
+		return sasr.accumulateByWeek()
+	case AccumulateOptionMonth:
+		return sasr.accumulateByMonth()
+	default:
+		// this should never happen
+		return nil, fmt.Errorf("unexpected error, invalid accumulateByType: %s", accumulateBy)
+	}
+}
+
+func (sasr *SummaryAllocationSetRange) accumulateByNone() (*SummaryAllocationSetRange, error) {
+	result, err := sasr.clone()
+	return result, err
+}
+
+func (sasr *SummaryAllocationSetRange) accumulateByAll() (*SummaryAllocationSetRange, error) {
+	var err error
+	var result *SummaryAllocationSet
+	result, err = sasr.newAccumulation()
+
+	if err != nil {
+		return nil, fmt.Errorf("error running accumulate: %s", err)
+	}
+	accumulated := NewSummaryAllocationSetRange(result)
+	return accumulated, nil
+}
+
+func (sasr *SummaryAllocationSetRange) accumulateByHour() (*SummaryAllocationSetRange, error) {
+	// ensure that the summary allocation sets have a 1-hour window, if a set exists
+	if len(sasr.SummaryAllocationSets) > 0 && sasr.SummaryAllocationSets[0].Window.Duration() != time.Hour {
+		return nil, fmt.Errorf("window duration must equal 1 hour; got:%s", sasr.SummaryAllocationSets[0].Window.Duration())
+	}
+
+	result, err := sasr.clone()
+	return result, err
+}
+
+func (sasr *SummaryAllocationSetRange) accumulateByDay() (*SummaryAllocationSetRange, error) {
+	// if the summary allocation set window is 1-day, just return the existing summary allocation set range
+	if len(sasr.SummaryAllocationSets) > 0 && sasr.SummaryAllocationSets[0].Window.Duration() == time.Hour*24 {
+		return sasr, nil
+	}
+
+	var toAccumulate *SummaryAllocationSetRange
+	result := NewSummaryAllocationSetRange()
+	for i, as := range sasr.SummaryAllocationSets {
+
+		if as.Window.Duration() != time.Hour {
+			return nil, fmt.Errorf("window duration must equal 1 hour; got:%s", as.Window.Duration())
+		}
+
+		hour := as.Window.Start().Hour()
+
+		if toAccumulate == nil {
+			toAccumulate = NewSummaryAllocationSetRange()
+			as = as.Clone()
+		}
+
+		err := toAccumulate.Append(as)
+		if err != nil {
+			return nil, fmt.Errorf("error building accumulation: %s", err)
+		}
+		sas, err := toAccumulate.accumulate()
+		if err != nil {
+			return nil, fmt.Errorf("error accumulating result: %s", err)
+		}
+		toAccumulate = NewSummaryAllocationSetRange(sas)
+
+		if hour == 23 || i == len(sasr.SummaryAllocationSets)-1 {
+			if length := len(toAccumulate.SummaryAllocationSets); length != 1 {
+				return nil, fmt.Errorf("failed accumulation, detected %d sets instead of 1", length)
+			}
+			err = result.Append(toAccumulate.SummaryAllocationSets[0])
+			if err != nil {
+				return nil, fmt.Errorf("error building result accumulation: %s", err)
+			}
+			toAccumulate = nil
+		}
+	}
+	return result, nil
+}
+
+func (sasr *SummaryAllocationSetRange) accumulateByMonth() (*SummaryAllocationSetRange, error) {
+	var toAccumulate *SummaryAllocationSetRange
+	result := NewSummaryAllocationSetRange()
+	for i, as := range sasr.SummaryAllocationSets {
+
+		if as.Window.Duration() != time.Hour*24 {
+			return nil, fmt.Errorf("window duration must equal 24 hours; got:%s", as.Window.Duration())
+		}
+
+		_, month, _ := as.Window.Start().Date()
+		_, nextDayMonth, _ := as.Window.Start().Add(time.Hour * 24).Date()
+
+		if toAccumulate == nil {
+			toAccumulate = NewSummaryAllocationSetRange()
+			as = as.Clone()
+		}
+
+		err := toAccumulate.Append(as)
+		if err != nil {
+			return nil, fmt.Errorf("error building monthly accumulation: %s", err)
+		}
+
+		sas, err := toAccumulate.accumulate()
+		if err != nil {
+			return nil, fmt.Errorf("error building monthly accumulation: %s", err)
+		}
+
+		toAccumulate = NewSummaryAllocationSetRange(sas)
+
+		// either the month has ended, or there are no more summary allocation sets
+		if month != nextDayMonth || i == len(sasr.SummaryAllocationSets)-1 {
+			if length := len(toAccumulate.SummaryAllocationSets); length != 1 {
+				return nil, fmt.Errorf("failed accumulation, detected %d sets instead of 1", length)
+			}
+			err = result.Append(toAccumulate.SummaryAllocationSets[0])
+			if err != nil {
+				return nil, fmt.Errorf("error building result accumulation: %s", err)
+			}
+			toAccumulate = nil
+		}
+	}
+	return result, nil
+}
+
+func (sasr *SummaryAllocationSetRange) accumulateByWeek() (*SummaryAllocationSetRange, error) {
+	var toAccumulate *SummaryAllocationSetRange
+	result := NewSummaryAllocationSetRange()
+	for i, as := range sasr.SummaryAllocationSets {
+		if as.Window.Duration() != time.Hour*24 {
+			return nil, fmt.Errorf("window duration must equal 24 hours; got:%s", as.Window.Duration())
+		}
+
+		dayOfWeek := as.Window.Start().Weekday()
+
+		if toAccumulate == nil {
+			toAccumulate = NewSummaryAllocationSetRange()
+			as = as.Clone()
+		}
+
+		err := toAccumulate.Append(as)
+		if err != nil {
+			return nil, fmt.Errorf("error building accumulation: %s", err)
+		}
+		sas, err := toAccumulate.accumulate()
+		if err != nil {
+			return nil, fmt.Errorf("error accumulating result: %s", err)
+		}
+		toAccumulate = NewSummaryAllocationSetRange(sas)
+
+		// current assumption is the week always ends on Saturday, or when there are no more summary allocation sets
+		if dayOfWeek == time.Saturday || i == len(sasr.SummaryAllocationSets)-1 {
+			if length := len(toAccumulate.SummaryAllocationSets); length != 1 {
+				return nil, fmt.Errorf("failed accumulation, detected %d sets instead of 1", length)
+			}
+			err = result.Append(toAccumulate.SummaryAllocationSets[0])
+			if err != nil {
+				return nil, fmt.Errorf("error building result accumulation: %s", err)
+			}
+			toAccumulate = nil
+		}
+	}
+	return result, nil
+}
+
+// clone returns a new SummaryAllocationSetRange cloned from the existing SASR
+func (sasr *SummaryAllocationSetRange) clone() (*SummaryAllocationSetRange, error) {
+	sasrSource := NewSummaryAllocationSetRange()
+	sasrSource.Window = sasr.Window.Clone()
+	sasrSource.Step = sasr.Step
+	sasrSource.Message = sasr.Message
+
+	for _, sas := range sasr.SummaryAllocationSets {
+		var sasClone *SummaryAllocationSet = nil
+		if sas != nil {
+			sasClone = sas.Clone()
+		}
+
+		err := sasrSource.Append(sasClone)
+		if err != nil {
+			return nil, err
+		}
+	}
+	return sasrSource, nil
+
+}

+ 116 - 0
pkg/kubecost/summaryallocation_json.go

@@ -0,0 +1,116 @@
+package kubecost
+
+import (
+	"math"
+	"time"
+)
+
+// SummaryAllocationResponse is a sanitized version of SummaryAllocation, which
+// formats fields and protects against issues like mashaling NaNs.
+type SummaryAllocationResponse struct {
+	Name                   string    `json:"name"`
+	Start                  time.Time `json:"start"`
+	End                    time.Time `json:"end"`
+	CPUCoreRequestAverage  *float64  `json:"cpuCoreRequestAverage"`
+	CPUCoreUsageAverage    *float64  `json:"cpuCoreUsageAverage"`
+	CPUCost                *float64  `json:"cpuCost"`
+	GPUCost                *float64  `json:"gpuCost"`
+	NetworkCost            *float64  `json:"networkCost"`
+	LoadBalancerCost       *float64  `json:"loadBalancerCost"`
+	PVCost                 *float64  `json:"pvCost"`
+	RAMBytesRequestAverage *float64  `json:"ramByteRequestAverage"`
+	RAMBytesUsageAverage   *float64  `json:"ramByteUsageAverage"`
+	RAMCost                *float64  `json:"ramCost"`
+	SharedCost             *float64  `json:"sharedCost"`
+	ExternalCost           *float64  `json:"externalCost"`
+}
+
+// ToResponse converts a SummaryAllocation to a SummaryAllocationResponse,
+// protecting against NaN and null values.
+func (sa *SummaryAllocation) ToResponse() *SummaryAllocationResponse {
+	if sa == nil {
+		return nil
+	}
+
+	return &SummaryAllocationResponse{
+		Name:                   sa.Name,
+		Start:                  sa.Start,
+		End:                    sa.End,
+		CPUCoreRequestAverage:  float64ToResponse(sa.CPUCoreRequestAverage),
+		CPUCoreUsageAverage:    float64ToResponse(sa.CPUCoreUsageAverage),
+		CPUCost:                float64ToResponse(sa.CPUCost),
+		GPUCost:                float64ToResponse(sa.GPUCost),
+		NetworkCost:            float64ToResponse(sa.NetworkCost),
+		LoadBalancerCost:       float64ToResponse(sa.LoadBalancerCost),
+		PVCost:                 float64ToResponse(sa.PVCost),
+		RAMBytesRequestAverage: float64ToResponse(sa.RAMBytesRequestAverage),
+		RAMBytesUsageAverage:   float64ToResponse(sa.RAMBytesUsageAverage),
+		RAMCost:                float64ToResponse(sa.RAMCost),
+		SharedCost:             float64ToResponse(sa.SharedCost),
+		ExternalCost:           float64ToResponse(sa.ExternalCost),
+	}
+}
+
+func float64ToResponse(f float64) *float64 {
+	if math.IsNaN(f) || math.IsInf(f, 0) {
+		return nil
+	}
+
+	return &f
+}
+
+// SummaryAllocationSetResponse is a sanitized version of SummaryAllocationSet,
+// which formats fields and protects against issues like marshaling NaNs.
+type SummaryAllocationSetResponse struct {
+	SummaryAllocations map[string]*SummaryAllocationResponse `json:"allocations"`
+	Window             Window                                `json:"window"`
+}
+
+// ToResponse converts a SummaryAllocationSet to a SummaryAllocationSetResponse,
+// protecting against NaN and null values.
+func (sas *SummaryAllocationSet) ToResponse() *SummaryAllocationSetResponse {
+	if sas == nil {
+		return nil
+	}
+
+	sars := make(map[string]*SummaryAllocationResponse, len(sas.SummaryAllocations))
+	for k, v := range sas.SummaryAllocations {
+		sars[k] = v.ToResponse()
+	}
+
+	return &SummaryAllocationSetResponse{
+		SummaryAllocations: sars,
+		Window:             sas.Window.Clone(),
+	}
+}
+
+// SummaryAllocationSetRangeResponse is a sanitized version of SummaryAllocationSetRange,
+// which formats fields and protects against issues like marshaling NaNs.
+type SummaryAllocationSetRangeResponse struct {
+	Step                  time.Duration                   `json:"step"`
+	SummaryAllocationSets []*SummaryAllocationSetResponse `json:"sets"`
+	Window                Window                          `json:"window"`
+}
+
+// ToResponse converts a SummaryAllocationSet to a SummaryAllocationSetResponse,
+// protecting against NaN and null values.
+func (sasr *SummaryAllocationSetRange) ToResponse() *SummaryAllocationSetRangeResponse {
+	if sasr == nil {
+		return nil
+	}
+
+	sasrr := make([]*SummaryAllocationSetResponse, len(sasr.SummaryAllocationSets))
+	for i, v := range sasr.SummaryAllocationSets {
+		sasrr[i] = v.ToResponse()
+	}
+
+	return &SummaryAllocationSetRangeResponse{
+		Step:                  sasr.Step,
+		SummaryAllocationSets: sasrr,
+		Window:                sasr.Window.Clone(),
+	}
+}
+
+func EmptySummaryAllocationSetRangeResponse() *SummaryAllocationSetRangeResponse {
+	return &SummaryAllocationSetRangeResponse{}
+}

+ 129 - 0
pkg/kubecost/summaryallocation_json_test.go

@@ -0,0 +1,129 @@
+package kubecost
+
+import (
+	"encoding/json"
+	"math"
+	"testing"
+	"time"
+
+	"github.com/opencost/opencost/pkg/util/timeutil"
+)
+
+func TestSummaryAllocationSetRangeResponse_MarshalJSON(t *testing.T) {
+	// Set a 1-day (start, end)
+	s := time.Date(2023, time.March, 13, 0, 0, 0, 0, time.UTC)
+	e := s.Add(timeutil.Day)
+
+	// Set some basic numbers that can be used to asset accuracy later
+	adjustment := -0.14
+	bytes := 2183471523842.00
+	cores := 2.78
+	cost := 12.50
+	gpus := 1.00
+
+	// Test a normal allocation for numerical accuracy
+	alloc := &Allocation{
+		Name: "cluster/node/namespace/pod/alloc",
+		Properties: &AllocationProperties{
+			Cluster:   "cluster",
+			Node:      "node",
+			Namespace: "namespace",
+			Pod:       "pod",
+			Container: "alloc",
+		},
+		CPUCoreHours:               cores,
+		CPUCoreRequestAverage:      cores,
+		CPUCoreUsageAverage:        cores,
+		CPUCost:                    cost,
+		CPUCostAdjustment:          adjustment,
+		GPUHours:                   gpus,
+		GPUCost:                    cost,
+		GPUCostAdjustment:          adjustment,
+		NetworkTransferBytes:       bytes,
+		NetworkReceiveBytes:        bytes,
+		NetworkCost:                cost,
+		NetworkCrossZoneCost:       cost,
+		NetworkCrossRegionCost:     cost,
+		NetworkInternetCost:        cost,
+		NetworkCostAdjustment:      adjustment,
+		LoadBalancerCost:           cost,
+		LoadBalancerCostAdjustment: adjustment,
+		PVs: PVAllocations{
+			PVKey{Cluster: "cluster", Name: "pv"}: &PVAllocation{
+				ByteHours: bytes,
+				Cost:      cost,
+			},
+		},
+		PVCostAdjustment:       adjustment,
+		RAMByteHours:           bytes,
+		RAMBytesRequestAverage: bytes,
+		RAMBytesUsageAverage:   bytes,
+		RAMCost:                cost,
+		RAMCostAdjustment:      adjustment,
+		SharedCost:             cost,
+		ExternalCost:           cost,
+	}
+
+	// Test an allocation with NaN values for JSON marshal errors
+	allocWithNaN := &Allocation{
+		Name: "cluster/node/namespace/pod/nan",
+		Properties: &AllocationProperties{
+			Cluster:   "cluster",
+			Node:      "node",
+			Namespace: "namespace",
+			Pod:       "pod",
+			Container: "nan",
+		},
+		CPUCoreHours:               math.NaN(),
+		CPUCoreRequestAverage:      math.NaN(),
+		CPUCoreUsageAverage:        math.NaN(),
+		CPUCost:                    math.NaN(),
+		CPUCostAdjustment:          math.NaN(),
+		GPUHours:                   gpus,
+		GPUCost:                    cost,
+		GPUCostAdjustment:          adjustment,
+		NetworkTransferBytes:       bytes,
+		NetworkReceiveBytes:        bytes,
+		NetworkCost:                cost,
+		NetworkCrossZoneCost:       cost,
+		NetworkCrossRegionCost:     cost,
+		NetworkInternetCost:        cost,
+		NetworkCostAdjustment:      adjustment,
+		LoadBalancerCost:           cost,
+		LoadBalancerCostAdjustment: adjustment,
+		PVs: PVAllocations{
+			PVKey{Cluster: "cluster", Name: "pv"}: &PVAllocation{
+				ByteHours: bytes,
+				Cost:      cost,
+			},
+		},
+		PVCostAdjustment:       adjustment,
+		RAMByteHours:           bytes,
+		RAMBytesRequestAverage: bytes,
+		RAMBytesUsageAverage:   bytes,
+		RAMCost:                cost,
+		RAMCostAdjustment:      adjustment,
+		SharedCost:             cost,
+		ExternalCost:           cost,
+	}
+
+	// Convert to SummaryAllocationSetRange
+	as := NewAllocationSet(s, e, alloc, allocWithNaN)
+	sas := NewSummaryAllocationSet(as, nil, nil, true, true)
+	sasr := NewSummaryAllocationSetRange(sas)
+
+	// Confirm that SummaryAllocationSetRange does error because on NaN
+	_, err := json.Marshal(sasr)
+	if err == nil {
+		t.Fatalf("expected NaN values to cause error")
+	}
+
+	// Convert to response
+	sasrr := sasr.ToResponse()
+
+	// Confirm that same SummaryAllocationSetRangeResponse does NOT error
+	_, err = json.Marshal(sasrr)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+}

+ 249 - 0
pkg/kubecost/summaryallocation_test.go

@@ -877,3 +877,252 @@ func TestSummaryAllocationSet_TotalEfficiency(t *testing.T) {
 		})
 	}
 }
+
+func TestSummaryAllocationSetRange_AccumulateBy_None(t *testing.T) {
+	ago4d := time.Now().UTC().Truncate(day).Add(-4 * day)
+	ago3d := time.Now().UTC().Truncate(day).Add(-3 * day)
+	ago2d := time.Now().UTC().Truncate(day).Add(-2 * day)
+	yesterday := time.Now().UTC().Truncate(day).Add(-day)
+	today := time.Now().UTC().Truncate(day)
+
+	ago4dSAS := NewMockUnitSummaryAllocationSet(ago4d, day)
+	ago4dSAS.Insert(NewMockUnitSummaryAllocation("4", ago4d, day, nil))
+	ago3dSAS := NewMockUnitSummaryAllocationSet(ago3d, day)
+	ago3dSAS.Insert(NewMockUnitSummaryAllocation("a", ago3d, day, nil))
+	ago2dSAS := NewMockUnitSummaryAllocationSet(ago2d, day)
+	ago2dSAS.Insert(NewMockUnitSummaryAllocation("", ago2d, day, nil))
+	yesterdaySAS := NewMockUnitSummaryAllocationSet(yesterday, day)
+	yesterdaySAS.Insert(NewMockUnitSummaryAllocation("", yesterday, day, nil))
+	todaySAS := NewMockUnitSummaryAllocationSet(today, day)
+	todaySAS.Insert(NewMockUnitSummaryAllocation("", today, day, nil))
+
+	asr := NewSummaryAllocationSetRange(ago4dSAS, ago3dSAS, ago2dSAS, yesterdaySAS, todaySAS)
+	asr, err := asr.Accumulate(AccumulateOptionNone)
+	if err != nil {
+		t.Fatalf("unexpected error calling accumulateBy: %s", err)
+	}
+
+	if len(asr.SummaryAllocationSets) != 5 {
+		t.Fatalf("expected 5 allocation sets, got:%d", len(asr.SummaryAllocationSets))
+	}
+}
+
+func TestSummaryAllocationSetRange_AccumulateBy_All(t *testing.T) {
+	ago4d := time.Now().UTC().Truncate(day).Add(-4 * day)
+	ago3d := time.Now().UTC().Truncate(day).Add(-3 * day)
+	ago2d := time.Now().UTC().Truncate(day).Add(-2 * day)
+	yesterday := time.Now().UTC().Truncate(day).Add(-day)
+	today := time.Now().UTC().Truncate(day)
+
+	ago4dSAS := NewMockUnitSummaryAllocationSet(ago4d, day)
+	ago4dSAS.Insert(NewMockUnitSummaryAllocation("4", ago4d, day, nil))
+	ago3dSAS := NewMockUnitSummaryAllocationSet(ago3d, day)
+	ago3dSAS.Insert(NewMockUnitSummaryAllocation("a", ago3d, day, nil))
+	ago2dSAS := NewMockUnitSummaryAllocationSet(ago2d, day)
+	ago2dSAS.Insert(NewMockUnitSummaryAllocation("", ago2d, day, nil))
+	yesterdaySAS := NewMockUnitSummaryAllocationSet(yesterday, day)
+	yesterdaySAS.Insert(NewMockUnitSummaryAllocation("", yesterday, day, nil))
+	todaySAS := NewMockUnitSummaryAllocationSet(today, day)
+	todaySAS.Insert(NewMockUnitSummaryAllocation("", today, day, nil))
+
+	asr := NewSummaryAllocationSetRange(ago4dSAS, ago3dSAS, ago2dSAS, yesterdaySAS, todaySAS)
+	asr, err := asr.Accumulate(AccumulateOptionAll)
+	if err != nil {
+		t.Fatalf("unexpected error calling accumulateBy: %s", err)
+	}
+
+	if len(asr.SummaryAllocationSets) != 1 {
+		t.Fatalf("expected 1 allocation set, got:%d", len(asr.SummaryAllocationSets))
+	}
+
+	allocMap := asr.SummaryAllocationSets[0].SummaryAllocations
+	alloc := allocMap["cluster1/namespace1/pod1/container1"]
+	if alloc.Minutes() != 4320.0 {
+		t.Errorf("accumulating AllocationSetRange: expected %f minutes; actual %f", 4320.0, alloc.Minutes())
+	}
+}
+
+func TestSummaryAllocationSetRange_AccumulateBy_Hour(t *testing.T) {
+	ago4h := time.Now().UTC().Truncate(time.Hour).Add(-4 * time.Hour)
+	ago3h := time.Now().UTC().Truncate(time.Hour).Add(-3 * time.Hour)
+	ago2h := time.Now().UTC().Truncate(time.Hour).Add(-2 * time.Hour)
+	ago1h := time.Now().UTC().Truncate(time.Hour).Add(-time.Hour)
+	currentHour := time.Now().UTC().Truncate(time.Hour)
+
+	ago4hAS := NewMockUnitSummaryAllocationSet(ago4h, time.Hour)
+	ago4hAS.Insert(NewMockUnitSummaryAllocation("4", ago4h, time.Hour, nil))
+	ago3hAS := NewMockUnitSummaryAllocationSet(ago3h, time.Hour)
+	ago3hAS.Insert(NewMockUnitSummaryAllocation("a", ago3h, time.Hour, nil))
+	ago2hAS := NewMockUnitSummaryAllocationSet(ago2h, time.Hour)
+	ago2hAS.Insert(NewMockUnitSummaryAllocation("", ago2h, time.Hour, nil))
+	ago1hAS := NewMockUnitSummaryAllocationSet(ago1h, time.Hour)
+	ago1hAS.Insert(NewMockUnitSummaryAllocation("", ago1h, time.Hour, nil))
+	currentHourAS := NewMockUnitSummaryAllocationSet(currentHour, time.Hour)
+	currentHourAS.Insert(NewMockUnitSummaryAllocation("", currentHour, time.Hour, nil))
+
+	asr := NewSummaryAllocationSetRange(ago4hAS, ago3hAS, ago2hAS, ago1hAS, currentHourAS)
+	asr, err := asr.Accumulate(AccumulateOptionHour)
+	if err != nil {
+		t.Fatalf("unexpected error calling accumulateBy: %s", err)
+	}
+
+	if len(asr.SummaryAllocationSets) != 5 {
+		t.Fatalf("expected 5 allocation sets, got:%d", len(asr.SummaryAllocationSets))
+	}
+
+	allocMap := asr.SummaryAllocationSets[0].SummaryAllocations
+	alloc := allocMap["4"]
+	if alloc.Minutes() != 60.0 {
+		t.Errorf("accumulating AllocationSetRange: expected %f minutes; actual %f", 60.0, alloc.Minutes())
+	}
+}
+
+func TestSummaryAllocationSetRange_AccumulateBy_Day_From_Day(t *testing.T) {
+	ago4d := time.Now().UTC().Truncate(day).Add(-4 * day)
+	ago3d := time.Now().UTC().Truncate(day).Add(-3 * day)
+	ago2d := time.Now().UTC().Truncate(day).Add(-2 * day)
+	yesterday := time.Now().UTC().Truncate(day).Add(-day)
+	today := time.Now().UTC().Truncate(day)
+
+	ago4dSAS := NewMockUnitSummaryAllocationSet(ago4d, day)
+	ago4dSAS.Insert(NewMockUnitSummaryAllocation("4", ago4d, day, nil))
+	ago3dSAS := NewMockUnitSummaryAllocationSet(ago3d, day)
+	ago3dSAS.Insert(NewMockUnitSummaryAllocation("a", ago3d, day, nil))
+	ago2dSAS := NewMockUnitSummaryAllocationSet(ago2d, day)
+	ago2dSAS.Insert(NewMockUnitSummaryAllocation("", ago2d, day, nil))
+	yesterdaySAS := NewMockUnitSummaryAllocationSet(yesterday, day)
+	yesterdaySAS.Insert(NewMockUnitSummaryAllocation("", yesterday, day, nil))
+	todaySAS := NewMockUnitSummaryAllocationSet(today, day)
+	todaySAS.Insert(NewMockUnitSummaryAllocation("", today, day, nil))
+
+	asr := NewSummaryAllocationSetRange(ago4dSAS, ago3dSAS, ago2dSAS, yesterdaySAS, todaySAS)
+	asr, err := asr.Accumulate(AccumulateOptionNone)
+	if err != nil {
+		t.Fatalf("unexpected error calling accumulateBy: %s", err)
+	}
+
+	if len(asr.SummaryAllocationSets) != 5 {
+		t.Fatalf("expected 5 allocation sets, got:%d", len(asr.SummaryAllocationSets))
+	}
+	allocMap := asr.SummaryAllocationSets[0].SummaryAllocations
+	alloc := allocMap["4"]
+	if alloc.Minutes() != 1440.0 {
+		t.Errorf("accumulating AllocationSetRange: expected %f minutes; actual %f", 1440.0, alloc.Minutes())
+	}
+}
+
+func TestSummaryAllocationSetRange_AccumulateBy_Day_From_Hours(t *testing.T) {
+	ago4h := time.Now().UTC().Truncate(time.Hour).Add(-4 * time.Hour)
+	ago3h := time.Now().UTC().Truncate(time.Hour).Add(-3 * time.Hour)
+	ago2h := time.Now().UTC().Truncate(time.Hour).Add(-2 * time.Hour)
+	ago1h := time.Now().UTC().Truncate(time.Hour).Add(-time.Hour)
+	currentHour := time.Now().UTC().Truncate(time.Hour)
+
+	ago4hAS := NewMockUnitSummaryAllocationSet(ago4h, time.Hour)
+	ago4hAS.Insert(NewMockUnitSummaryAllocation("", ago4h, time.Hour, nil))
+	ago3hAS := NewMockUnitSummaryAllocationSet(ago3h, time.Hour)
+	ago3hAS.Insert(NewMockUnitSummaryAllocation("", ago3h, time.Hour, nil))
+	ago2hAS := NewMockUnitSummaryAllocationSet(ago2h, time.Hour)
+	ago2hAS.Insert(NewMockUnitSummaryAllocation("", ago2h, time.Hour, nil))
+	ago1hAS := NewMockUnitSummaryAllocationSet(ago1h, time.Hour)
+	ago1hAS.Insert(NewMockUnitSummaryAllocation("", ago1h, time.Hour, nil))
+	currentHourAS := NewMockUnitSummaryAllocationSet(currentHour, time.Hour)
+	currentHourAS.Insert(NewMockUnitSummaryAllocation("", currentHour, time.Hour, nil))
+
+	asr := NewSummaryAllocationSetRange(ago4hAS, ago3hAS, ago2hAS, ago1hAS, currentHourAS)
+	asr, err := asr.Accumulate(AccumulateOptionDay)
+	if err != nil {
+		t.Fatalf("unexpected error calling accumulateBy: %s", err)
+	}
+
+	if len(asr.SummaryAllocationSets) != 1 && len(asr.SummaryAllocationSets) != 2 {
+		t.Fatalf("expected 1 allocation set, got:%d", len(asr.SummaryAllocationSets))
+	}
+
+	allocMap := asr.SummaryAllocationSets[0].SummaryAllocations
+	alloc := allocMap["cluster1/namespace1/pod1/container1"]
+	if alloc.Minutes() > 300.0 {
+		t.Errorf("accumulating AllocationSetRange: expected %f or less minutes; actual %f", 300.0, alloc.Minutes())
+	}
+}
+
+func TestSummaryAllocationSetRange_AccumulateBy_Week(t *testing.T) {
+	ago9d := time.Now().UTC().Truncate(day).Add(-9 * day)
+	ago8d := time.Now().UTC().Truncate(day).Add(-8 * day)
+	ago7d := time.Now().UTC().Truncate(day).Add(-7 * day)
+	ago6d := time.Now().UTC().Truncate(day).Add(-6 * day)
+	ago5d := time.Now().UTC().Truncate(day).Add(-5 * day)
+	ago4d := time.Now().UTC().Truncate(day).Add(-4 * day)
+	ago3d := time.Now().UTC().Truncate(day).Add(-3 * day)
+	ago2d := time.Now().UTC().Truncate(day).Add(-2 * day)
+	yesterday := time.Now().UTC().Truncate(day).Add(-day)
+	today := time.Now().UTC().Truncate(day)
+
+	ago9dAS := NewMockUnitSummaryAllocationSet(ago9d, day)
+	ago9dAS.Insert(NewMockUnitSummaryAllocation("4", ago9d, day, nil))
+	ago8dAS := NewMockUnitSummaryAllocationSet(ago8d, day)
+	ago8dAS.Insert(NewMockUnitSummaryAllocation("4", ago8d, day, nil))
+	ago7dAS := NewMockUnitSummaryAllocationSet(ago7d, day)
+	ago7dAS.Insert(NewMockUnitSummaryAllocation("4", ago7d, day, nil))
+	ago6dAS := NewMockUnitSummaryAllocationSet(ago6d, day)
+	ago6dAS.Insert(NewMockUnitSummaryAllocation("4", ago6d, day, nil))
+	ago5dAS := NewMockUnitSummaryAllocationSet(ago5d, day)
+	ago5dAS.Insert(NewMockUnitSummaryAllocation("4", ago5d, day, nil))
+	ago4dAS := NewMockUnitSummaryAllocationSet(ago4d, day)
+	ago4dAS.Insert(NewMockUnitSummaryAllocation("4", ago4d, day, nil))
+	ago3dAS := NewMockUnitSummaryAllocationSet(ago3d, day)
+	ago3dAS.Insert(NewMockUnitSummaryAllocation("4", ago3d, day, nil))
+	ago2dAS := NewMockUnitSummaryAllocationSet(ago2d, day)
+	ago2dAS.Insert(NewMockUnitSummaryAllocation("4", ago2d, day, nil))
+	yesterdayAS := NewMockUnitSummaryAllocationSet(yesterday, day)
+	yesterdayAS.Insert(NewMockUnitSummaryAllocation("", yesterday, day, nil))
+	todayAS := NewMockUnitSummaryAllocationSet(today, day)
+	todayAS.Insert(NewMockUnitSummaryAllocation("4", today, day, nil))
+
+	asr := NewSummaryAllocationSetRange(ago9dAS, ago8dAS, ago7dAS, ago6dAS, ago5dAS, ago4dAS, ago3dAS, ago2dAS, yesterdayAS, todayAS)
+	asr, err := asr.Accumulate(AccumulateOptionWeek)
+	if err != nil {
+		t.Fatalf("unexpected error calling accumulateBy: %s", err)
+	}
+
+	if len(asr.SummaryAllocationSets) != 2 && len(asr.SummaryAllocationSets) != 3 {
+		t.Fatalf("expected 2 allocation sets, got:%d", len(asr.SummaryAllocationSets))
+	}
+
+	for _, as := range asr.SummaryAllocationSets {
+		if as.Window.Duration() < time.Hour*24 || as.Window.Duration() > time.Hour*24*7 {
+			t.Fatalf("expected window duration to be between 1 and 7 days, got:%s", as.Window.Duration().String())
+		}
+	}
+}
+
+func TestSummaryAllocationSetRange_AccumulateBy_Month(t *testing.T) {
+	prevMonth1stDay := time.Date(2020, 01, 29, 0, 0, 0, 0, time.UTC)
+	prevMonth2ndDay := time.Date(2020, 01, 30, 0, 0, 0, 0, time.UTC)
+	prevMonth3ndDay := time.Date(2020, 01, 31, 0, 0, 0, 0, time.UTC)
+	nextMonth1stDay := time.Date(2020, 02, 01, 0, 0, 0, 0, time.UTC)
+
+	prev1AS := NewMockUnitSummaryAllocationSet(prevMonth1stDay, day)
+	prev1AS.Insert(NewMockUnitSummaryAllocation("", prevMonth1stDay, day, nil))
+	prev2AS := NewMockUnitSummaryAllocationSet(prevMonth2ndDay, day)
+	prev2AS.Insert(NewMockUnitSummaryAllocation("", prevMonth2ndDay, day, nil))
+	prev3AS := NewMockUnitSummaryAllocationSet(prevMonth3ndDay, day)
+	prev3AS.Insert(NewMockUnitSummaryAllocation("", prevMonth3ndDay, day, nil))
+	nextAS := NewMockUnitSummaryAllocationSet(nextMonth1stDay, day)
+	nextAS.Insert(NewMockUnitSummaryAllocation("", nextMonth1stDay, day, nil))
+	asr := NewSummaryAllocationSetRange(prev1AS, prev2AS, prev3AS, nextAS)
+	asr, err := asr.Accumulate(AccumulateOptionMonth)
+	if err != nil {
+		t.Fatalf("unexpected error calling accumulateBy: %s", err)
+	}
+
+	if len(asr.SummaryAllocationSets) != 2 {
+		t.Fatalf("expected 2 allocation sets, got:%d", len(asr.SummaryAllocationSets))
+	}
+
+	for _, as := range asr.SummaryAllocationSets {
+		if as.Window.Duration() < time.Hour*24 || as.Window.Duration() > time.Hour*24*31 {
+			t.Fatalf("expected window duration to be between 1 and 7 days, got:%s", as.Window.Duration().String())
+		}
+	}
+}

+ 39 - 4
pkg/kubecost/window.go

@@ -2,17 +2,17 @@ package kubecost
 
 import (
 	"bytes"
+	"encoding/json"
 	"fmt"
-	"github.com/opencost/opencost/pkg/log"
 	"math"
 	"regexp"
 	"strconv"
 	"time"
 
-	"github.com/opencost/opencost/pkg/util/timeutil"
-
 	"github.com/opencost/opencost/pkg/env"
+	"github.com/opencost/opencost/pkg/log"
 	"github.com/opencost/opencost/pkg/thanos"
+	"github.com/opencost/opencost/pkg/util/timeutil"
 )
 
 const (
@@ -479,7 +479,6 @@ func (w Window) IsOpen() bool {
 	return w.start == nil || w.end == nil
 }
 
-// TODO:CLEANUP make this unmarshalable (make Start and End public)
 func (w Window) MarshalJSON() ([]byte, error) {
 	buffer := bytes.NewBufferString("{")
 	if w.start != nil {
@@ -496,6 +495,42 @@ func (w Window) MarshalJSON() ([]byte, error) {
 	return buffer.Bytes(), nil
 }
 
+func (w *Window) UnmarshalJSON(bs []byte) error {
+	// Due to the behavior of our custom MarshalJSON, we unmarshal as strings
+	// and then manually handle the weird quoted "null" case.
+	type PubWindow struct {
+		Start string `json:"start"`
+		End   string `json:"end"`
+	}
+	var pw PubWindow
+	err := json.Unmarshal(bs, &pw)
+	if err != nil {
+		return fmt.Errorf("half unmarshal: %w", err)
+	}
+
+	var start *time.Time
+	var end *time.Time
+
+	if pw.Start != "null" {
+		t, err := time.Parse(time.RFC3339, pw.Start)
+		if err != nil {
+			return fmt.Errorf("parsing start as RFC3339: %w", err)
+		}
+		start = &t
+	}
+	if pw.End != "null" {
+		t, err := time.Parse(time.RFC3339, pw.End)
+		if err != nil {
+			return fmt.Errorf("parsing end as RFC3339: %w", err)
+		}
+		end = &t
+	}
+
+	w.start = start
+	w.end = end
+	return nil
+}
+
 func (w Window) Minutes() float64 {
 	if w.IsOpen() {
 		return math.Inf(1)

+ 45 - 1
pkg/kubecost/window_test.go

@@ -1,12 +1,15 @@
 package kubecost
 
 import (
+	"encoding/json"
 	"fmt"
-	"github.com/opencost/opencost/pkg/util/timeutil"
 	"strings"
 	"testing"
 	"time"
 
+	"github.com/google/go-cmp/cmp"
+	"github.com/opencost/opencost/pkg/util/timeutil"
+
 	"github.com/opencost/opencost/pkg/env"
 )
 
@@ -1146,3 +1149,44 @@ func TestWindow_GetWindowsForQueryWindow(t *testing.T) {
 		})
 	}
 }
+
+func TestMarshalUnmarshal(t *testing.T) {
+	t1 := time.Date(2023, 03, 11, 01, 29, 15, 0, time.UTC)
+	t2 := t1.Add(8 * time.Minute)
+	cases := []struct {
+		w Window
+	}{
+		{
+			w: NewClosedWindow(t1, t2),
+		},
+		{
+			w: NewWindow(&t1, nil),
+		},
+		{
+			w: NewWindow(nil, &t2),
+		},
+		{
+			w: NewWindow(nil, nil),
+		},
+	}
+
+	for _, c := range cases {
+		name := c.w.String()
+		t.Run(name, func(t *testing.T) {
+			marshaled, err := json.Marshal(c.w)
+			if err != nil {
+				t.Fatalf("marshaling: %s", err)
+			}
+
+			var unmarshaledW Window
+			err = json.Unmarshal(marshaled, &unmarshaledW)
+			if err != nil {
+				t.Fatalf("unmarshaling: %s", err)
+			}
+
+			if diff := cmp.Diff(c.w, unmarshaledW); len(diff) > 0 {
+				t.Errorf(diff)
+			}
+		})
+	}
+}

+ 60 - 15
pkg/util/allocationfilterutil/queryfilters.go

@@ -10,6 +10,51 @@ import (
 	"github.com/opencost/opencost/pkg/util/mapper"
 )
 
+const (
+	ParamFilterClusters        = "filterClusters"
+	ParamFilterNodes           = "filterNodes"
+	ParamFilterNamespaces      = "filterNamespaces"
+	ParamFilterControllerKinds = "filterControllerKinds"
+	ParamFilterControllers     = "filterControllers"
+	ParamFilterPods            = "filterPods"
+	ParamFilterContainers      = "filterContainers"
+
+	ParamFilterDepartments  = "filterDepartments"
+	ParamFilterEnvironments = "filterEnvironments"
+	ParamFilterOwners       = "filterOwners"
+	ParamFilterProducts     = "filterProducts"
+	ParamFilterTeams        = "filterTeams"
+
+	ParamFilterAnnotations = "filterAnnotations"
+	ParamFilterLabels      = "filterLabels"
+	ParamFilterServices    = "filterServices"
+)
+
+// AllHTTPParamKeys returns all HTTP GET parameters used for v1 filters. It is
+// intended to help validate HTTP queries in handlers to help avoid e.g.
+// spelling errors.
+func AllHTTPParamKeys() []string {
+	return []string{
+		ParamFilterClusters,
+		ParamFilterNodes,
+		ParamFilterNamespaces,
+		ParamFilterControllerKinds,
+		ParamFilterControllers,
+		ParamFilterPods,
+		ParamFilterContainers,
+
+		ParamFilterDepartments,
+		ParamFilterEnvironments,
+		ParamFilterOwners,
+		ParamFilterProducts,
+		ParamFilterTeams,
+
+		ParamFilterAnnotations,
+		ParamFilterLabels,
+		ParamFilterServices,
+	}
+}
+
 // ============================================================================
 // This file contains:
 // Parsing (HTTP query params -> AllocationFilter) for V1 of filters
@@ -83,7 +128,7 @@ func AllocationFilterFromParamsV1(
 	// filter structs (they evaluate to true always) there could be overhead
 	// when calling Matches() repeatedly for no purpose.
 
-	if filterClusters := qp.GetList("filterClusters", ","); len(filterClusters) > 0 {
+	if filterClusters := qp.GetList(ParamFilterClusters, ","); len(filterClusters) > 0 {
 		clustersOr := kubecost.AllocationFilterOr{
 			Filters: []kubecost.AllocationFilter{},
 		}
@@ -116,15 +161,15 @@ func AllocationFilterFromParamsV1(
 		filter.Filters = append(filter.Filters, clustersOr)
 	}
 
-	if raw := qp.GetList("filterNodes", ","); len(raw) > 0 {
+	if raw := qp.GetList(ParamFilterNodes, ","); len(raw) > 0 {
 		filter.Filters = append(filter.Filters, filterV1SingleValueFromList(raw, kubecost.FilterNode))
 	}
 
-	if raw := qp.GetList("filterNamespaces", ","); len(raw) > 0 {
+	if raw := qp.GetList(ParamFilterNamespaces, ","); len(raw) > 0 {
 		filter.Filters = append(filter.Filters, filterV1SingleValueFromList(raw, kubecost.FilterNamespace))
 	}
 
-	if raw := qp.GetList("filterControllerKinds", ","); len(raw) > 0 {
+	if raw := qp.GetList(ParamFilterControllerKinds, ","); len(raw) > 0 {
 		filter.Filters = append(filter.Filters, filterV1SingleValueFromList(raw, kubecost.FilterControllerKind))
 	}
 
@@ -132,7 +177,7 @@ func AllocationFilterFromParamsV1(
 	// "deployment:kubecost-cost-analyzer"
 	//
 	// Thus, we have to make a custom OR filter for this condition.
-	if filterControllers := qp.GetList("filterControllers", ","); len(filterControllers) > 0 {
+	if filterControllers := qp.GetList(ParamFilterControllers, ","); len(filterControllers) > 0 {
 		controllersOr := kubecost.AllocationFilterOr{
 			Filters: []kubecost.AllocationFilter{},
 		}
@@ -183,44 +228,44 @@ func AllocationFilterFromParamsV1(
 		}
 	}
 
-	if raw := qp.GetList("filterPods", ","); len(raw) > 0 {
+	if raw := qp.GetList(ParamFilterPods, ","); len(raw) > 0 {
 		filter.Filters = append(filter.Filters, filterV1SingleValueFromList(raw, kubecost.FilterPod))
 	}
 
-	if raw := qp.GetList("filterContainers", ","); len(raw) > 0 {
+	if raw := qp.GetList(ParamFilterContainers, ","); len(raw) > 0 {
 		filter.Filters = append(filter.Filters, filterV1SingleValueFromList(raw, kubecost.FilterContainer))
 	}
 
 	// Label-mapped queries require a label config to be present.
 	if labelConfig != nil {
-		if raw := qp.GetList("filterDepartments", ","); len(raw) > 0 {
+		if raw := qp.GetList(ParamFilterDepartments, ","); len(raw) > 0 {
 			filter.Filters = append(filter.Filters, filterV1LabelAliasMappedFromList(raw, labelConfig.DepartmentLabel))
 		}
-		if raw := qp.GetList("filterEnvironments", ","); len(raw) > 0 {
+		if raw := qp.GetList(ParamFilterEnvironments, ","); len(raw) > 0 {
 			filter.Filters = append(filter.Filters, filterV1LabelAliasMappedFromList(raw, labelConfig.EnvironmentLabel))
 		}
-		if raw := qp.GetList("filterOwners", ","); len(raw) > 0 {
+		if raw := qp.GetList(ParamFilterOwners, ","); len(raw) > 0 {
 			filter.Filters = append(filter.Filters, filterV1LabelAliasMappedFromList(raw, labelConfig.OwnerLabel))
 		}
-		if raw := qp.GetList("filterProducts", ","); len(raw) > 0 {
+		if raw := qp.GetList(ParamFilterProducts, ","); len(raw) > 0 {
 			filter.Filters = append(filter.Filters, filterV1LabelAliasMappedFromList(raw, labelConfig.ProductLabel))
 		}
-		if raw := qp.GetList("filterTeams", ","); len(raw) > 0 {
+		if raw := qp.GetList(ParamFilterTeams, ","); len(raw) > 0 {
 			filter.Filters = append(filter.Filters, filterV1LabelAliasMappedFromList(raw, labelConfig.TeamLabel))
 		}
 	} else {
 		log.Debugf("No label config is available. Not creating filters for label-mapped 'fields'.")
 	}
 
-	if raw := qp.GetList("filterAnnotations", ","); len(raw) > 0 {
+	if raw := qp.GetList(ParamFilterAnnotations, ","); len(raw) > 0 {
 		filter.Filters = append(filter.Filters, filterV1DoubleValueFromList(raw, kubecost.FilterAnnotation))
 	}
 
-	if raw := qp.GetList("filterLabels", ","); len(raw) > 0 {
+	if raw := qp.GetList(ParamFilterLabels, ","); len(raw) > 0 {
 		filter.Filters = append(filter.Filters, filterV1DoubleValueFromList(raw, kubecost.FilterLabel))
 	}
 
-	if filterServices := qp.GetList("filterServices", ","); len(filterServices) > 0 {
+	if filterServices := qp.GetList(ParamFilterServices, ","); len(filterServices) > 0 {
 		// filterServices= is the only filter that uses the "contains" operator.
 		servicesFilter := kubecost.AllocationFilterOr{
 			Filters: []kubecost.AllocationFilter{},

+ 60 - 12
pkg/util/httputil/httputil.go

@@ -17,27 +17,75 @@ import (
 //  QueryParams
 //--------------------------------------------------------------------------
 
-type QueryParams = mapper.PrimitiveMap
+// valuesPrimitiveMap implements mapper.PrimitiveMap so we can build extra
+// functionality into the QueryParams interface.
+type valuesPrimitiveMap struct {
+	url.Values
+}
 
-// queryParamsMap is mapper.Map adapter for url.Values
-type queryParamsMap struct {
-	values url.Values
+func (values valuesPrimitiveMap) Has(key string) bool {
+	return values.Values.Has(key)
+}
+func (values valuesPrimitiveMap) Get(key string) string {
+	return values.Values.Get(key)
+}
+func (values valuesPrimitiveMap) Set(key, value string) error {
+	values.Values.Set(key, value)
+	return nil
 }
 
-// mapper.Getter implementation
-func (qpm *queryParamsMap) Get(key string) string {
-	return qpm.values.Get(key)
+// QueryParams provides basic map access to URL values as well as providing
+// helpful additional functionality for validation.
+type QueryParams interface {
+	mapper.PrimitiveMap
+
+	// InvalidKeys returns the set of param keys which are not present in the
+	// possible valid set. It is a set subtraction: present - valid = invalid
+	//
+	// Example usage to catch a typo:
+	// qp.InvalidKeys([]string{"window", "aggregate", "filterClusters"}) ->
+	//   "filterClsuters"
+	//
+	// If qp contains no keys, then this should always return an empty slice/nil
+	InvalidKeys(possibleValidKeys []string) (invalidKeys []string)
 }
 
-// mapper.Setter implementation
-func (qpm *queryParamsMap) Set(key, value string) error {
-	qpm.values.Set(key, value)
-	return nil
+// queryParamsMap implements the QueryParams interface on top of
+// valuesPrimitiveMap.
+type queryParamsMap struct {
+	values url.Values
+	mapper.PrimitiveMap
 }
 
 // NewQueryParams creates a primitive map using the request query parameters
 func NewQueryParams(values url.Values) QueryParams {
-	return mapper.NewMapper(&queryParamsMap{values})
+	vpm := valuesPrimitiveMap{values}
+
+	return &queryParamsMap{
+		values:       values,
+		PrimitiveMap: mapper.NewMapper(vpm),
+	}
+}
+
+// InvalidKeys performs a set difference: Params keys - possible valid keys.
+//
+// For now, dealing with cache busting parameters should be the handler's
+// responsibility.
+func (qpm *queryParamsMap) InvalidKeys(possibleValidKeys []string) []string {
+	validMap := map[string]struct{}{}
+	for _, validKey := range possibleValidKeys {
+		validMap[validKey] = struct{}{}
+	}
+
+	var invalidKeys []string
+
+	for key := range qpm.values {
+		if _, ok := validMap[key]; !ok {
+			invalidKeys = append(invalidKeys, key)
+		}
+	}
+
+	return invalidKeys
 }
 
 //--------------------------------------------------------------------------

+ 18 - 0
pkg/util/httputil/httputil_test.go

@@ -2,9 +2,27 @@ package httputil
 
 import (
 	"net/http"
+	"net/url"
 	"testing"
+
+	"github.com/google/go-cmp/cmp"
 )
 
+func TestInvalidKeys(t *testing.T) {
+	vals := url.Values{}
+	vals.Set("window", "7d")
+	vals.Set("aggregate", "namespace")
+	vals.Set("filterClsuters", "cluster-two") // Intentional typo
+
+	qp := NewQueryParams(vals)
+
+	result := qp.InvalidKeys([]string{"window", "aggregate", "filterClusters", "filterNamespaces"})
+	expected := []string{"filterClsuters"}
+	if diff := cmp.Diff(result, expected); len(diff) > 0 {
+		t.Errorf("Expected: %+v. Got: %+v", expected, result)
+	}
+}
+
 func TestHeaderString(t *testing.T) {
 	h := make(http.Header)
 	h.Add("foo", "abc")

+ 16 - 1
pkg/util/mapper/mapper.go

@@ -12,9 +12,11 @@ import (
 //  Contracts
 //--------------------------------------------------------------------------
 
-// Getter is an interface that retrieves a string value for a string key
+// Getter is an interface that retrieves a string value for a string key and
+// can check for the existence of a string key.
 type Getter interface {
 	Get(key string) string
+	Has(key string) bool
 }
 
 // Setter is an interface that sets the value of a string key to a string value.
@@ -32,6 +34,9 @@ type Map interface {
 // PrimitiveMapReader is an implementation contract for an object capable
 // of reading primitive values from a util.Map
 type PrimitiveMapReader interface {
+	// Has checks if the map contains the given key.
+	Has(key string) bool
+
 	// Get parses an string from the map key parameter. If the value
 	// is empty, the defaultValue parameter is returned.
 	Get(key string, defaultValue string) string
@@ -161,6 +166,12 @@ type GoMap struct {
 	m map[string]string
 }
 
+// Has implements mapper.Haser
+func (gm *GoMap) Has(key string) bool {
+	_, ok := gm.m[key]
+	return ok
+}
+
 // Get implements mapper.Getter
 func (gm *GoMap) Get(key string) string {
 	return gm.m[key]
@@ -236,6 +247,10 @@ func NewCompositionMapper(getter Getter, setter Setter) PrimitiveMap {
 	}
 }
 
+func (rom *readOnlyMapper) Has(key string) bool {
+	return rom.getter.Has(key)
+}
+
 // Get parses an string from the read-only mapper key parameter. If the value
 // is empty, the defaultValue parameter is returned.
 func (rom *readOnlyMapper) Get(key string, defaultValue string) string {

+ 6 - 1
ui/Dockerfile

@@ -3,10 +3,15 @@ ADD package*.json /opt/ui/
 WORKDIR /opt/ui
 RUN npm install
 ADD src /opt/ui/src
-ENV BASE_URL=/model
 RUN npx parcel build src/index.html
 
 FROM nginx:alpine
 COPY --from=builder /opt/ui/dist /var/www
 COPY default.nginx.conf /etc/nginx/conf.d/
 COPY nginx.conf /etc/nginx/
+
+ENV BASE_URL=/model
+
+COPY ./docker-entrypoint.sh /usr/local/bin/
+ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
+CMD ["nginx", "-g", "daemon off;"]

+ 41 - 0
ui/README.md

@@ -20,3 +20,44 @@ After following the installation instructions, access the UI by port forwarding:
 ```
 kubectl port-forward --namespace opencost service/opencost 9090
 ```
+
+## Running Locally
+
+The UI can be run locally using the `npm run serve` command.
+
+```sh
+$ npm run serve
+> kubecost-ui-open@0.0.1 serve
+> npx parcel serve src/index.html
+
+Server running at http://localhost:1234
+✨ Built in 1.96s
+```
+
+And can have a custom URL backend prefix.
+
+```sh
+BASE_URL=http://localhost:9090/test npm run serve
+
+> kubecost-ui-open@0.0.1 serve
+> npx parcel serve src/index.html
+
+Server running at http://localhost:1234
+✨ Built in 772ms
+```
+
+In addition, similar behavior can be replicated with the docker container:
+
+```sh
+$ docker run -e BASE_URL_OVERRIDE=test -p 9091:9090 -d opencost-ui:latest
+$ curl localhost:9091
+<html gibberish> 
+```
+
+## Overriding the Base API URL
+
+For some use cases such as the case of [Opencost deployed behind an ingress controller](https://github.com/opencost/opencost/issues/1677), it is useful to override the `BASE_URL` variable responsible for requests sent from the UI to the API.  This means that instead of sending requests to `<domain>/model/allocation/compute/etc`, requests can be sent to `<domain>/{BASE_URL_OVERRIDE}/allocation/compute/etc`.  To do this, supply the environment variable `BASE_URL_OVERRIDE` to the docker image.
+
+```sh
+$ docker run -p 9091:9090 -e BASE_URL_OVERRIDE=anything -d opencost-ui:latest
+```

+ 13 - 0
ui/docker-entrypoint.sh

@@ -0,0 +1,13 @@
+#!/bin/sh
+set -e
+
+if [[ ! -z "$BASE_URL_OVERRIDE" ]]; then
+    echo "running with BASE_URL=${BASE_URL_OVERRIDE}"
+    sed -i "s^{PLACEHOLDER_BASE_URL}^$BASE_URL_OVERRIDE^g" /var/www/*.js
+else 
+    echo "running with BASE_URL=${BASE_URL}"
+    sed -i "s^{PLACEHOLDER_BASE_URL}^$BASE_URL^g" /var/www/*.js
+fi
+
+# Run the parent (nginx) container's entrypoint script
+exec /docker-entrypoint.sh "$@"

Разница между файлами не показана из-за своего большого размера
+ 712 - 696
ui/package-lock.json


+ 3 - 0
ui/package.json

@@ -3,6 +3,9 @@
   "version": "0.0.1",
   "description": "Open source UI for Kubecost",
   "scripts": {
+    "build": "npx parcel build src/index.html",
+    "serve": "npx parcel serve src/index.html",
+    "clean": "rm -rf dist/*",
     "test": "echo \"Error: no test specified\" && exit 1",
     "preinstall": "npx npm-force-resolutions"
   },

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

@@ -1,9 +1,13 @@
 import axios from 'axios';
 
 class AllocationService {
-  BASE_URL = process.env.BASE_URL || 'http://localhost:9090/model';
+  BASE_URL = process.env.BASE_URL || '{PLACEHOLDER_BASE_URL}';
 
   async fetchAllocation(win, aggregate, options) {
+    if (this.BASE_URL.includes('PLACEHOLDER_BASE_URL')) {
+      this.BASE_URL = `http://localhost:9090/model`
+    }
+    
     const { accumulate, filters, } = options;
     const params = {
       window: win,

Некоторые файлы не были показаны из-за большого количества измененных файлов