فهرست منبع

Merge branch 'develop' into develop

Ajay Tripathy 3 سال پیش
والد
کامیت
514ba9363f

+ 179 - 8
pkg/cloud/awsprovider.go

@@ -5,7 +5,6 @@ import (
 	"compress/gzip"
 	"context"
 	"encoding/csv"
-	"errors"
 	"fmt"
 	"io"
 	"net/http"
@@ -25,6 +24,7 @@ import (
 	"github.com/opencost/opencost/pkg/util"
 	"github.com/opencost/opencost/pkg/util/fileutil"
 	"github.com/opencost/opencost/pkg/util/json"
+	"github.com/opencost/opencost/pkg/util/timeutil"
 
 	awsSDK "github.com/aws/aws-sdk-go-v2/aws"
 	"github.com/aws/aws-sdk-go-v2/config"
@@ -51,6 +51,13 @@ const (
 	APIPricingSource              = "Public API"
 	SpotPricingSource             = "Spot Data Feed"
 	ReservedInstancePricingSource = "Savings Plan, Reserved Instance, and Out-Of-Cluster"
+
+	InUseState    = "in-use"
+	AttachedState = "attached"
+
+	AWSHourlyPublicIPCost    = 0.005
+	EKSCapacityTypeLabel     = "eks.amazonaws.com/capacityType"
+	EKSCapacitySpotTypeValue = "SPOT"
 )
 
 var (
@@ -58,6 +65,7 @@ var (
 	provIdRx      = regexp.MustCompile("aws:///([^/]+)/([^/]+)")
 	usageTypeRegx = regexp.MustCompile(".*(-|^)(EBS.+)")
 	versionRx     = regexp.MustCompile("^#Version: (\\d+)\\.\\d+$")
+	regionRx      = regexp.MustCompile("([a-z]+-[a-z]+-[0-9])")
 )
 
 func (aws *AWS) PricingSourceStatus() map[string]*PricingSource {
@@ -631,6 +639,9 @@ func (k *awsKey) ID() string {
 	return ""
 }
 
+// Features will return a comma seperated list of features for the given node
+// If the node has a spot label, it will be included in the list
+// Otherwise, the list include instance type, operating system, and the region
 func (k *awsKey) Features() string {
 
 	instanceType, _ := util.GetInstanceType(k.Labels)
@@ -638,7 +649,7 @@ func (k *awsKey) Features() string {
 	region, _ := util.GetRegion(k.Labels)
 
 	key := region + "," + instanceType + "," + operatingSystem
-	usageType := PreemptibleType
+	usageType := k.getUsageType(k.Labels)
 	spotKey := key + "," + usageType
 	if l, ok := k.Labels["lifecycle"]; ok && l == "EC2Spot" {
 		return spotKey
@@ -646,9 +657,23 @@ func (k *awsKey) Features() string {
 	if l, ok := k.Labels[k.SpotLabelName]; ok && l == k.SpotLabelValue {
 		return spotKey
 	}
+	if usageType == PreemptibleType {
+		return spotKey
+	}
 	return key
 }
 
+// getUsageType returns the usage type of the instance
+// If the instance is a spot instance, it will return PreemptibleType
+// Otherwise returns an empty string
+func (k *awsKey) getUsageType(labels map[string]string) string {
+	if label, ok := labels[EKSCapacityTypeLabel]; ok && label == EKSCapacitySpotTypeValue {
+		// We currently write out spot instances as "preemptible" in the pricing data, so these need to match
+		return PreemptibleType
+	}
+	return ""
+}
+
 func (aws *AWS) PVPricing(pvk PVKey) (*PV, error) {
 	pricing, ok := aws.Pricing[pvk.Features()]
 	if !ok {
@@ -816,6 +841,7 @@ func (aws *AWS) DownloadPricingData() error {
 
 	inputkeys := make(map[string]bool)
 	for _, n := range nodeList {
+
 		if _, ok := n.Labels["eks.amazonaws.com/nodegroup"]; ok {
 			aws.clusterManagementPrice = 0.10
 			aws.clusterProvisioner = "EKS"
@@ -1452,8 +1478,7 @@ func (aws *AWS) getAddressesForRegion(ctx context.Context, region string) (*ec2.
 	return cli.DescribeAddresses(ctx, &ec2.DescribeAddressesInput{})
 }
 
-// GetAddresses retrieves EC2 addresses
-func (aws *AWS) GetAddresses() ([]byte, error) {
+func (aws *AWS) getAllAddresses() ([]*ec2Types.Address, error) {
 	aws.ConfigureAuth() // load authentication data into env vars
 
 	addressCh := make(chan *ec2.DescribeAddressesOutput, len(awsRegions))
@@ -1509,6 +1534,16 @@ func (aws *AWS) GetAddresses() ([]byte, error) {
 		return nil, fmt.Errorf("%d error(s) retrieving addresses: %v", len(errs), errs)
 	}
 
+	return addresses, nil
+}
+
+// GetAddresses retrieves EC2 addresses
+func (aws *AWS) GetAddresses() ([]byte, error) {
+	addresses, err := aws.getAllAddresses()
+	if err != nil {
+		return nil, err
+	}
+
 	// Format the response this way to match the JSON-encoded formatting of a single response
 	// from DescribeAddresss, so that consumers can always expect AWS disk responses to have
 	// a "Addresss" key at the top level.
@@ -1517,6 +1552,14 @@ func (aws *AWS) GetAddresses() ([]byte, error) {
 	})
 }
 
+func (aws *AWS) isAddressOrphaned(address *ec2Types.Address) bool {
+	if address.AssociationId != nil {
+		return false
+	}
+
+	return true
+}
+
 func (aws *AWS) getDisksForRegion(ctx context.Context, region string, maxResults int32, nextToken *string) (*ec2.DescribeVolumesOutput, error) {
 	aak, err := aws.GetAWSAccessKey()
 	if err != nil {
@@ -1535,8 +1578,7 @@ func (aws *AWS) getDisksForRegion(ctx context.Context, region string, maxResults
 	})
 }
 
-// GetDisks returns the AWS disks backing PVs. Useful because sometimes k8s will not clean up PVs correctly. Requires a json config in /var/configs with key region.
-func (aws *AWS) GetDisks() ([]byte, error) {
+func (aws *AWS) getAllDisks() ([]*ec2Types.Volume, error) {
 	aws.ConfigureAuth() // load authentication data into env vars
 
 	volumeCh := make(chan *ec2.DescribeVolumesOutput, len(awsRegions))
@@ -1602,6 +1644,16 @@ func (aws *AWS) GetDisks() ([]byte, error) {
 		return nil, fmt.Errorf("%d error(s) retrieving volumes: %v", len(errs), errs)
 	}
 
+	return volumes, nil
+}
+
+// GetDisks returns the AWS disks backing PVs. Useful because sometimes k8s will not clean up PVs correctly. Requires a json config in /var/configs with key region.
+func (aws *AWS) GetDisks() ([]byte, error) {
+	volumes, err := aws.getAllDisks()
+	if err != nil {
+		return nil, err
+	}
+
 	// Format the response this way to match the JSON-encoded formatting of a single response
 	// from DescribeVolumes, so that consumers can always expect AWS disk responses to have
 	// a "Volumes" key at the top level.
@@ -1610,8 +1662,127 @@ func (aws *AWS) GetDisks() ([]byte, error) {
 	})
 }
 
-func (*AWS) GetOrphanedResources() ([]OrphanedResource, error) {
-	return nil, errors.New("not implemented")
+func (aws *AWS) isDiskOrphaned(vol *ec2Types.Volume) bool {
+	// Do not consider volume orphaned if in use
+	if vol.State == InUseState {
+		return false
+	}
+
+	// Do not consider volume orphaned if volume is attached to any attachments
+	if len(vol.Attachments) != 0 {
+		for _, attachment := range vol.Attachments {
+			if attachment.State == AttachedState {
+				return false
+			}
+		}
+	}
+
+	return true
+}
+
+func (aws *AWS) GetOrphanedResources() ([]OrphanedResource, error) {
+	volumes, err := aws.getAllDisks()
+	if err != nil {
+		return nil, err
+	}
+
+	addresses, err := aws.getAllAddresses()
+	if err != nil {
+		return nil, err
+	}
+
+	var orphanedResources []OrphanedResource
+
+	for _, volume := range volumes {
+		if aws.isDiskOrphaned(volume) {
+			cost, err := aws.findCostForDisk(volume)
+			if err != nil {
+				return nil, err
+			}
+
+			var volumeSize int64
+			if volume.Size != nil {
+				volumeSize = int64(*volume.Size)
+			}
+
+			// This is turning us-east-1a into us-east-1
+			var zone string
+			if volume.AvailabilityZone != nil {
+				zone = *volume.AvailabilityZone
+			}
+			var region, url string
+			region = regionRx.FindString(zone)
+			if region != "" {
+				url = "https://console.aws.amazon.com/ec2/home?region=" + region + "#Volumes:sort=desc:createTime"
+			} else {
+				url = "https://console.aws.amazon.com/ec2/home?#Volumes:sort=desc:createTime"
+			}
+
+			or := OrphanedResource{
+				Kind:        "disk",
+				Region:      zone,
+				Size:        &volumeSize,
+				DiskName:    *volume.VolumeId,
+				Url:         url,
+				MonthlyCost: cost,
+			}
+
+			orphanedResources = append(orphanedResources, or)
+		}
+	}
+
+	for _, address := range addresses {
+		if aws.isAddressOrphaned(address) {
+			cost := AWSHourlyPublicIPCost * timeutil.HoursPerMonth
+
+			desc := map[string]string{}
+			for _, tag := range address.Tags {
+				if tag.Key == nil {
+					continue
+				}
+				if tag.Value == nil {
+					desc[*tag.Key] = ""
+				} else {
+					desc[*tag.Key] = *tag.Value
+				}
+			}
+
+			or := OrphanedResource{
+				Kind:        "address",
+				Address:     *address.PublicIp,
+				Description: desc,
+				Url:         "http://console.aws.amazon.com/ec2/home?#Addresses",
+				MonthlyCost: &cost,
+			}
+
+			orphanedResources = append(orphanedResources, or)
+		}
+	}
+	return orphanedResources, nil
+}
+
+func (aws *AWS) findCostForDisk(disk *ec2Types.Volume) (*float64, error) {
+	//todo: use AWS pricing from all regions
+	if disk.AvailabilityZone == nil {
+		return nil, fmt.Errorf("nil region")
+	}
+	if disk.Size == nil {
+		return nil, fmt.Errorf("nil disk size")
+	}
+
+	class := volTypes[string(disk.VolumeType)]
+
+	key := "us-east-2" + "," + class
+
+	priceStr := aws.Pricing[key].PV.Cost
+
+	price, err := strconv.ParseFloat(priceStr, 64)
+	if err != nil {
+		return nil, err
+	}
+
+	cost := price * timeutil.HoursPerMonth * float64(*disk.Size)
+	return &cost, nil
 }
 
 // QueryAthenaPaginated executes athena query and processes results.

+ 66 - 0
pkg/cloud/awsprovider_test.go

@@ -0,0 +1,66 @@
+package cloud
+
+import "testing"
+
+func Test_awsKey_getUsageType(t *testing.T) {
+	type fields struct {
+		Labels     map[string]string
+		ProviderID string
+	}
+	type args struct {
+		labels map[string]string
+	}
+	tests := []struct {
+		name   string
+		fields fields
+		args   args
+		want   string
+	}{
+		{
+			// test with no labels should return false
+			name: "Label does not have the capacityType label associated with it",
+			args: args{
+				labels: map[string]string{},
+			},
+			want: "",
+		},
+		{
+			name: "label with a capacityType set to empty string should return empty string",
+			args: args{
+				labels: map[string]string{
+					EKSCapacityTypeLabel: "",
+				},
+			},
+			want: "",
+		},
+		{
+			name: "label with capacityType set to a random value should return empty string",
+			args: args{
+				labels: map[string]string{
+					EKSCapacityTypeLabel: "TEST_ME",
+				},
+			},
+			want: "",
+		},
+		{
+			name: "label with capacityType set to spot should return spot",
+			args: args{
+				labels: map[string]string{
+					EKSCapacityTypeLabel: EKSCapacitySpotTypeValue,
+				},
+			},
+			want: PreemptibleType,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			k := &awsKey{
+				Labels:     tt.fields.Labels,
+				ProviderID: tt.fields.ProviderID,
+			}
+			if got := k.getUsageType(tt.args.labels); got != tt.want {
+				t.Errorf("getUsageType() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}

+ 20 - 9
pkg/cloud/azureprovider.go

@@ -1247,7 +1247,7 @@ func (az *Azure) getDisks() ([]*compute.Disk, error) {
 	return disks, nil
 }
 
-func isDiskOrphaned(disk *compute.Disk) bool {
+func (az *Azure) isDiskOrphaned(disk *compute.Disk) bool {
 	//TODO: needs better algorithm
 	return disk.DiskState == "Unattached" || disk.DiskState == "Reserved"
 }
@@ -1261,7 +1261,7 @@ func (az *Azure) GetOrphanedResources() ([]OrphanedResource, error) {
 	var orphanedResources []OrphanedResource
 
 	for _, d := range disks {
-		if isDiskOrphaned(d) {
+		if az.isDiskOrphaned(d) {
 			cost, err := az.findCostForDisk(d)
 			if err != nil {
 				return nil, err
@@ -1277,14 +1277,25 @@ func (az *Azure) GetOrphanedResources() ([]OrphanedResource, error) {
 				diskRegion = *d.Location
 			}
 
+			var diskSize int64
+			if d.DiskSizeGB != nil {
+				diskSize = int64(*d.DiskSizeGB)
+			}
+
+			desc := map[string]string{}
+			for k, v := range d.Tags {
+				if v == nil {
+					desc[k] = ""
+				} else {
+					desc[k] = *v
+				}
+			}
+
 			or := OrphanedResource{
-				Kind:   "disk",
-				Region: diskRegion,
-				Description: map[string]string{
-					"diskState":   string(d.DiskState),
-					"timeCreated": d.TimeCreated.String(),
-				},
-				Size:        d.DiskSizeGB,
+				Kind:        "disk",
+				Region:      diskRegion,
+				Description: desc,
+				Size:        &diskSize,
 				DiskName:    diskName,
 				MonthlyCost: &cost,
 			}

+ 176 - 6
pkg/cloud/gcpprovider.go

@@ -2,12 +2,12 @@ package cloud
 
 import (
 	"context"
-	"errors"
 	"fmt"
 	"io"
 	"math"
 	"net/http"
 	"os"
+	"path"
 	"regexp"
 	"strconv"
 	"strings"
@@ -36,6 +36,14 @@ import (
 const GKE_GPU_TAG = "cloud.google.com/gke-accelerator"
 const BigqueryUpdateType = "bigqueryupdate"
 
+const (
+	GCPHourlyPublicIPCost = 0.01
+
+	GCPMonthlyBasicDiskCost = 0.04
+	GCPMonthlySSDDiskCost   = 0.17
+	GCPMonthlyGP2DiskCost   = 0.1
+)
+
 // List obtained by installing the `gcloud` CLI tool,
 // logging into gcp account, and running command
 // `gcloud compute regions list`
@@ -335,7 +343,7 @@ func (gcp *GCP) ClusterManagementPricing() (string, float64, error) {
 	return gcp.clusterProvisioner, gcp.clusterManagementPrice, nil
 }
 
-func (gcp *GCP) GetAddresses() ([]byte, error) {
+func (gcp *GCP) getAllAddresses() (*compute.AddressAggregatedList, error) {
 	projID, err := gcp.metadataClient.ProjectID()
 	if err != nil {
 		return nil, err
@@ -346,20 +354,36 @@ func (gcp *GCP) GetAddresses() ([]byte, error) {
 	if err != nil {
 		return nil, err
 	}
+
 	svc, err := compute.New(client)
 	if err != nil {
 		return nil, err
 	}
+
 	res, err := svc.Addresses.AggregatedList(projID).Do()
 
 	if err != nil {
 		return nil, err
 	}
+
+	return res, nil
+}
+
+func (gcp *GCP) GetAddresses() ([]byte, error) {
+	res, err := gcp.getAllAddresses()
+	if err != nil {
+		return nil, err
+	}
+
 	return json.Marshal(res)
 }
 
-// GetDisks returns the GCP disks backing PVs. Useful because sometimes k8s will not clean up PVs correctly. Requires a json config in /var/configs with key region.
-func (gcp *GCP) GetDisks() ([]byte, error) {
+func (gcp *GCP) isAddressOrphaned(address *compute.Address) bool {
+	// Consider address orphaned if it has 0 users
+	return len(address.Users) == 0
+}
+
+func (gcp *GCP) getAllDisks() (*compute.DiskAggregatedList, error) {
 	projID, err := gcp.metadataClient.ProjectID()
 	if err != nil {
 		return nil, err
@@ -370,21 +394,167 @@ func (gcp *GCP) GetDisks() ([]byte, error) {
 	if err != nil {
 		return nil, err
 	}
+
 	svc, err := compute.New(client)
 	if err != nil {
 		return nil, err
 	}
+
 	res, err := svc.Disks.AggregatedList(projID).Do()
 
 	if err != nil {
 		return nil, err
 	}
+
+	return res, nil
+}
+
+// GetDisks returns the GCP disks backing PVs. Useful because sometimes k8s will not clean up PVs correctly. Requires a json config in /var/configs with key region.
+func (gcp *GCP) GetDisks() ([]byte, error) {
+	res, err := gcp.getAllDisks()
+	if err != nil {
+		return nil, err
+	}
+
 	return json.Marshal(res)
+}
 
+func (gcp *GCP) isDiskOrphaned(disk *compute.Disk) (bool, error) {
+	// Do not consider disk orphaned if it has more than 0 users
+	if len(disk.Users) > 0 {
+		return false, nil
+	}
+
+	// Do not consider disk orphaned if it was used within the last hour
+	threshold := time.Now().Add(time.Duration(-1) * time.Hour)
+	if disk.LastDetachTimestamp != "" {
+		lastUsed, err := time.Parse(time.RFC3339, disk.LastDetachTimestamp)
+		if err != nil {
+			// This can return false since errors are checked before the bool
+			return false, fmt.Errorf("error parsing time: %s", err)
+		}
+		if threshold.Before(lastUsed) {
+			return false, nil
+		}
+	}
+	return true, nil
 }
 
-func (*GCP) GetOrphanedResources() ([]OrphanedResource, error) {
-	return nil, errors.New("not implemented")
+func (gcp *GCP) GetOrphanedResources() ([]OrphanedResource, error) {
+	disks, err := gcp.getAllDisks()
+	if err != nil {
+		return nil, err
+	}
+
+	addresses, err := gcp.getAllAddresses()
+	if err != nil {
+		return nil, err
+	}
+
+	var orphanedResources []OrphanedResource
+
+	for _, diskList := range disks.Items {
+		if len(diskList.Disks) == 0 {
+			continue
+		}
+
+		for _, disk := range diskList.Disks {
+			isOrphaned, err := gcp.isDiskOrphaned(disk)
+			if err != nil {
+				return nil, err
+			}
+			if isOrphaned {
+				cost, err := gcp.findCostForDisk(disk)
+				if err != nil {
+					return nil, err
+				}
+
+				// GCP gives us description as a string formatted as a map[string]string, so we need to
+				// deconstruct it back into a map[string]string to match the OR struct
+				desc := map[string]string{}
+				if err := json.Unmarshal([]byte(disk.Description), &desc); err != nil {
+					return nil, fmt.Errorf("error converting string to map: %s", err)
+				}
+
+				// Converts https://www.googleapis.com/compute/v1/projects/xxxxx/zones/us-central1-c to us-central1-c
+				zone := path.Base(disk.Zone)
+				if zone == "." {
+					zone = ""
+				}
+
+				or := OrphanedResource{
+					Kind:        "disk",
+					Region:      zone,
+					Description: desc,
+					Size:        &disk.SizeGb,
+					DiskName:    disk.Name,
+					Url:         disk.SelfLink,
+					MonthlyCost: cost,
+				}
+				orphanedResources = append(orphanedResources, or)
+			}
+		}
+	}
+
+	for _, addressList := range addresses.Items {
+		if len(addressList.Addresses) == 0 {
+			continue
+		}
+
+		for _, address := range addressList.Addresses {
+			if gcp.isAddressOrphaned(address) {
+				//todo: use GCP pricing
+				cost := GCPHourlyPublicIPCost * timeutil.HoursPerMonth
+
+				// Converts https://www.googleapis.com/compute/v1/projects/xxxxx/regions/us-central1 to us-central1
+				region := path.Base(address.Region)
+				if region == "." {
+					region = ""
+				}
+
+				or := OrphanedResource{
+					Kind:   "address",
+					Region: region,
+					Description: map[string]string{
+						"type": address.AddressType,
+					},
+					Address:     address.Address,
+					Url:         address.SelfLink,
+					MonthlyCost: &cost,
+				}
+				orphanedResources = append(orphanedResources, or)
+			}
+		}
+	}
+
+	return orphanedResources, nil
+}
+
+func (gcp *GCP) findCostForDisk(disk *compute.Disk) (*float64, error) {
+	//todo: use GCP pricing struct
+	price := GCPMonthlyBasicDiskCost
+	if strings.Contains(disk.Type, "ssd") {
+		price = GCPMonthlySSDDiskCost
+	}
+	if strings.Contains(disk.Type, "gp2") {
+		price = GCPMonthlyGP2DiskCost
+	}
+	cost := price * float64(disk.SizeGb)
+
+	// This isn't much use but I (Nick) think its could be going down the
+	// right path. Disk region isnt returning anything (and if it did its
+	// a url, same with type). Currently the only region stored in the
+	// Pricing struct is uscentral-1, so that would need to be fixed
+	// key := disk.Region + "," + disk.Type
+
+	// priceStr := gcp.Pricing[key].PV.Cost
+	// price, err := strconv.ParseFloat(priceStr, 64)
+	// if err != nil {
+	// 	return nil, err
+	// }
+
+	// cost := price * timeutil.HoursPerMonth * float64(disk.SizeGb)
+	return &cost, nil
 }
 
 // GCPPricing represents GCP pricing data for a SKU

+ 2 - 1
pkg/cloud/provider.go

@@ -108,8 +108,9 @@ type OrphanedResource struct {
 	Kind        string            `json:"resourceKind"`
 	Region      string            `json:"region"`
 	Description map[string]string `json:"description"`
-	Size        *int32            `json:"diskSizeInGB,omitempty"`
+	Size        *int64            `json:"diskSizeInGB,omitempty"`
 	DiskName    string            `json:"diskName,omitempty"`
+	Url         string            `json:"url"`
 	Address     string            `json:"ipAddress,omitempty"`
 	MonthlyCost *float64          `json:"monthlyCost"`
 }

+ 27 - 27
pkg/kubecost/allocation_test.go

@@ -413,8 +413,8 @@ func TestAllocationSet_generateKey(t *testing.T) {
 	}
 
 	key = alloc.generateKey(props, nil)
-	if key != "cluster1//app=app1" {
-		t.Fatalf("generateKey: expected \"cluster1//app=app1\"; actual \"%s\"", key)
+	if key != "cluster1//app1" {
+		t.Fatalf("generateKey: expected \"cluster1//app1\"; actual \"%s\"", key)
 	}
 
 	alloc.Properties = &AllocationProperties{
@@ -426,8 +426,8 @@ func TestAllocationSet_generateKey(t *testing.T) {
 		},
 	}
 	key = alloc.generateKey(props, nil)
-	if key != "cluster1/namespace1/app=app1" {
-		t.Fatalf("generateKey: expected \"cluster1/namespace1/app=app1\"; actual \"%s\"", key)
+	if key != "cluster1/namespace1/app1" {
+		t.Fatalf("generateKey: expected \"cluster1/namespace1/app1\"; actual \"%s\"", key)
 	}
 
 	props = []string{
@@ -552,15 +552,15 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 	//     idle:                                  20.00   5.00  15.00   0.00   0.00   0.00   0.00
 	//     namespace1:
 	//       pod1:
-	//         container1: [app=app1, env=env1]   16.00   1.00  11.00   1.00   1.00   1.00   1.00
+	//         container1: [app1, env1]   16.00   1.00  11.00   1.00   1.00   1.00   1.00
 	//       pod-abc: (deployment1)
 	//         container2:                         6.00   1.00   1.00   1.00   1.00   1.00   1.00
 	//       pod-def: (deployment1)
 	//         container3:                         6.00   1.00   1.00   1.00   1.00   1.00   1.00
 	//     namespace2:
 	//       pod-ghi: (deployment2)
-	//         container4: [app=app2, env=env2]    6.00   1.00   1.00   1.00   1.00   1.00   1.00
-	//         container5: [app=app2, env=env2]    6.00   1.00   1.00   1.00   1.00   1.00   1.00
+	//         container4: [app2, env2]    6.00   1.00   1.00   1.00   1.00   1.00   1.00
+	//         container5: [app2, env2]    6.00   1.00   1.00   1.00   1.00   1.00   1.00
 	//       pod-jkl: (daemonset1)
 	//         container6: {service1}              6.00   1.00   1.00   1.00   1.00   1.00   1.00
 	// +-----------------------------------------+------+------+------+------+------+------+------+
@@ -570,16 +570,16 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 	//     idle:                                  10.00   5.00   5.00   0.00   0.00   0.00   0.00
 	//     namespace2:
 	//       pod-mno: (deployment2)
-	//         container4: [app=app2]              6.00   1.00   1.00   1.00   1.00   1.00   1.00
-	//         container5: [app=app2]              6.00   1.00   1.00   1.00   1.00   1.00   1.00
+	//         container4: [app2]              6.00   1.00   1.00   1.00   1.00   1.00   1.00
+	//         container5: [app2]              6.00   1.00   1.00   1.00   1.00   1.00   1.00
 	//       pod-pqr: (daemonset1)
 	//         container6: {service1}              6.00   1.00   1.00   1.00   1.00   1.00   1.00
 	//     namespace3:
 	//       pod-stu: (deployment3)
-	//         container7: an[team=team1]          6.00   1.00   1.00   1.00   1.00   1.00   1.00
+	//         container7: an[team1]          6.00   1.00   1.00   1.00   1.00   1.00   1.00
 	//       pod-vwx: (statefulset1)
-	//         container8: an[team=team2]          6.00   1.00   1.00   1.00   1.00   1.00   1.00
-	//         container9: an[team=team1]          6.00   1.00   1.00   1.00   1.00   1.00   1.00
+	//         container8: an[team2]          6.00   1.00   1.00   1.00   1.00   1.00   1.00
+	//         container9: an[team1]          6.00   1.00   1.00   1.00   1.00   1.00   1.00
 	// +----------------------------------------+------+------+------+------+------+------+------+
 	//   cluster2 subtotal                        46.00  11.00  11.00   6.00   6.00   6.00   6.00
 	// +----------------------------------------+------+------+------+------+------+------+------+
@@ -843,8 +843,8 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 			numResults: numLabelApps + numIdle + numUnallocated,
 			totalCost:  activeTotalCost + idleTotalCost,
 			results: map[string]float64{
-				"app=app1":        16.00,
-				"app=app2":        24.00,
+				"app1":            16.00,
+				"app2":            24.00,
 				IdleSuffix:        30.00,
 				UnallocatedSuffix: 42.00,
 			},
@@ -878,8 +878,8 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 			numResults: 2 + numIdle + numUnallocated,
 			totalCost:  activeTotalCost + idleTotalCost,
 			results: map[string]float64{
-				"team=team1":      12.00,
-				"team=team2":      6.00,
+				"team1":           12.00,
+				"team2":           6.00,
 				IdleSuffix:        30.00,
 				UnallocatedSuffix: 64.00,
 			},
@@ -933,10 +933,10 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 			totalCost:  activeTotalCost + idleTotalCost,
 			// sets should be {idle, unallocated, app1/env1, app2/env2, app2/unallocated}
 			results: map[string]float64{
-				"app=app1/env=env1":                         16.00,
-				"app=app2/env=env2":                         12.00,
-				"app=app2/" + UnallocatedSuffix:             12.00,
-				IdleSuffix:                                  30.00,
+				"app1/env1":                 16.00,
+				"app2/env2":                 12.00,
+				"app2/" + UnallocatedSuffix: 12.00,
+				IdleSuffix:                  30.00,
 				UnallocatedSuffix + "/" + UnallocatedSuffix: 42.00,
 			},
 			windowStart: startYesterday,
@@ -951,11 +951,11 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 			numResults: 6,
 			totalCost:  activeTotalCost + idleTotalCost,
 			results: map[string]float64{
-				"cluster1/app=app2/env=env2": 12.00,
-				"__idle__":                   30.00,
-				"cluster1/app=app1/env=env1": 16.00,
+				"cluster1/app2/env2": 12.00,
+				"__idle__":           30.00,
+				"cluster1/app1/env1": 16.00,
 				"cluster1/" + UnallocatedSuffix + "/" + UnallocatedSuffix: 18.00,
-				"cluster2/app=app2/" + UnallocatedSuffix:                  12.00,
+				"cluster2/app2/" + UnallocatedSuffix:                      12.00,
 				"cluster2/" + UnallocatedSuffix + "/" + UnallocatedSuffix: 24.00,
 			},
 			windowStart: startYesterday,
@@ -971,12 +971,12 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 			totalCost:  activeTotalCost + idleTotalCost,
 			results: map[string]float64{
 				"pod-jkl/" + UnallocatedSuffix: 6.00,
-				"pod-stu/team=team1":           6.00,
+				"pod-stu/team1":                6.00,
 				"pod-abc/" + UnallocatedSuffix: 6.00,
 				"pod-pqr/" + UnallocatedSuffix: 6.00,
 				"pod-def/" + UnallocatedSuffix: 6.00,
-				"pod-vwx/team=team1":           6.00,
-				"pod-vwx/team=team2":           6.00,
+				"pod-vwx/team1":                6.00,
+				"pod-vwx/team2":                6.00,
 				"pod1/" + UnallocatedSuffix:    16.00,
 				"pod-mno/" + UnallocatedSuffix: 12.00,
 				"pod-ghi/" + UnallocatedSuffix: 12.00,

+ 2 - 2
pkg/kubecost/allocationprops.go

@@ -298,7 +298,7 @@ func (p *AllocationProperties) GenerateKey(aggregateBy []string, labelConfig *La
 			} else {
 				labelName := labelConfig.Sanitize(strings.TrimPrefix(agg, "label:"))
 				if labelValue, ok := labels[labelName]; ok {
-					names = append(names, fmt.Sprintf("%s=%s", labelName, labelValue))
+					names = append(names, fmt.Sprintf("%s", labelValue))
 				} else {
 					names = append(names, UnallocatedSuffix)
 				}
@@ -310,7 +310,7 @@ func (p *AllocationProperties) GenerateKey(aggregateBy []string, labelConfig *La
 			} else {
 				annotationName := labelConfig.Sanitize(strings.TrimPrefix(agg, "annotation:"))
 				if annotationValue, ok := annotations[annotationName]; ok {
-					names = append(names, fmt.Sprintf("%s=%s", annotationName, annotationValue))
+					names = append(names, fmt.Sprintf("%s", annotationValue))
 				} else {
 					names = append(names, UnallocatedSuffix)
 				}

+ 60 - 0
pkg/util/allocationfilterutil/v2/parser_test.go

@@ -42,6 +42,66 @@ func TestParse(t *testing.T) {
 				allocGenerator(kubecost.AllocationProperties{Namespace: "kube-system"}),
 			},
 		},
+		{
+			input: `cluster:"cluster-one"+namespace:"kubecost"+controllerKind:"daemonset"+controllerName:"kubecost-network-costs"+container:"kubecost-network-costs"`,
+			expected: kubecost.AllocationFilterAnd{[]kubecost.AllocationFilter{
+				kubecost.AllocationFilterOr{[]kubecost.AllocationFilter{
+					kubecost.AllocationFilterCondition{
+						Field: kubecost.FilterClusterID,
+						Op:    kubecost.FilterEquals,
+						Value: "cluster-one",
+					},
+				}},
+				kubecost.AllocationFilterOr{[]kubecost.AllocationFilter{
+					kubecost.AllocationFilterCondition{
+						Field: kubecost.FilterNamespace,
+						Op:    kubecost.FilterEquals,
+						Value: "kubecost",
+					},
+				}},
+				kubecost.AllocationFilterOr{[]kubecost.AllocationFilter{
+					kubecost.AllocationFilterCondition{
+						Field: kubecost.FilterControllerKind,
+						Op:    kubecost.FilterEquals,
+						Value: "daemonset",
+					},
+				}},
+				kubecost.AllocationFilterOr{[]kubecost.AllocationFilter{
+					kubecost.AllocationFilterCondition{
+						Field: kubecost.FilterControllerName,
+						Op:    kubecost.FilterEquals,
+						Value: "kubecost-network-costs",
+					},
+				}},
+				kubecost.AllocationFilterOr{[]kubecost.AllocationFilter{
+					kubecost.AllocationFilterCondition{
+						Field: kubecost.FilterContainer,
+						Op:    kubecost.FilterEquals,
+						Value: "kubecost-network-costs",
+					},
+				}},
+			}},
+			shouldMatch: []kubecost.Allocation{
+				allocGenerator(kubecost.AllocationProperties{
+					Cluster:        "cluster-one",
+					Namespace:      "kubecost",
+					ControllerKind: "daemonset",
+					Controller:     "kubecost-network-costs",
+					Pod:            "kubecost-network-costs-abc123",
+					Container:      "kubecost-network-costs",
+				}),
+			},
+			shouldNotMatch: []kubecost.Allocation{
+				allocGenerator(kubecost.AllocationProperties{
+					Cluster:        "cluster-one",
+					Namespace:      "default",
+					ControllerKind: "deployment",
+					Controller:     "workload-abc",
+					Pod:            "workload-abc-123abc",
+					Container:      "abc",
+				}),
+			},
+		},
 		{
 			input: `namespace!:"kubecost","kube-system"`,
 			expected: kubecost.AllocationFilterAnd{[]kubecost.AllocationFilter{

+ 8 - 0
spec/opencost-specv01.md

@@ -283,6 +283,14 @@ Asset Idle Cost can be calculated by individual assets, groups of assets, cluste
 
 Workload Idle Costs is a cost-weighted measurement of [requested](https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/#resource-requests-and-limits-of-pod-and-container) resources that are unused. Workload Idle Costs can be calculated on any grouping of Kubernetes workloads, e.g. containers, pods, labels, annotations, namespaces, etc.
 
+## Pod States
+
+The state of a pod will affect the ability to assign costs and whether a resource is considered allocated. The OpenCost model does not account for resources allocated to pods with `ImagePullBackOff`.
+
+| State | Cost Allocation | Status |
+|---|---|---|
+| Running | Max (Usage, request) | Implemented |
+| ImagePullBackOff | Request | Currently no charge |
 
 ## Glossary
 

+ 1 - 0
ui/default.nginx.conf

@@ -58,6 +58,7 @@ server {
     add_header ETag "1.96.0";
     listen 9090;
     listen [::]:9090;
+    resolver 127.0.0.1 valid=5s;
     location /healthz {
         return 200 'OK';
     }