Przeglądaj źródła

Merge branch 'develop' into regions

Matt Ray 2 lat temu
rodzic
commit
f978158155
40 zmienionych plików z 1130 dodań i 809 usunięć
  1. 1 0
      .gitignore
  2. 1 0
      CONTRIBUTING.md
  3. 1 0
      MAINTAINERS.md
  4. 1 1
      Tiltfile
  5. 1 1
      justfile
  6. 20 40
      pkg/cloud/azure/authorizer.go
  7. 124 0
      pkg/cloud/azure/storageauthorizer.go
  8. 27 22
      pkg/cloud/azure/storagebillingparser.go
  9. 10 10
      pkg/cloud/azure/storageconfiguration.go
  10. 86 28
      pkg/cloud/azure/storageconfiguration_test.go
  11. 10 28
      pkg/cloud/azure/storageconnection.go
  12. 3 3
      pkg/cloud/config/configurations_test.go
  13. 7 1
      pkg/cloud/scaleway/provider.go
  14. 3 0
      pkg/cloudcost/memoryrepository_test.go
  15. 1 1
      pkg/cloudcost/querier.go
  16. 1 11
      pkg/cloudcost/queryservice.go
  17. 5 0
      pkg/cloudcost/queryservice_helper.go
  18. 4 4
      pkg/cloudcost/queryservice_helper_test.go
  19. 23 14
      pkg/cloudcost/repositoryquerier.go
  20. 23 3
      pkg/cloudcost/view.go
  21. 12 1
      pkg/cmd/costmodel/costmodel.go
  22. 5 7
      pkg/costmodel/cluster_helpers.go
  23. 72 0
      pkg/costmodel/cluster_helpers_test.go
  24. 61 10
      pkg/costmodel/costmodel.go
  25. 45 38
      pkg/costmodel/metrics.go
  26. 14 0
      pkg/env/costmodelenv.go
  27. 38 0
      pkg/env/costmodelenv_test.go
  28. 3 3
      pkg/kubecost/asset.go
  29. 10 1
      ui/Dockerfile
  30. 10 1
      ui/Dockerfile.cross
  31. 3 3
      ui/default.nginx.conf.template
  32. 6 2
      ui/docker-entrypoint.sh
  33. 7 0
      ui/justfile
  34. 451 519
      ui/package-lock.json
  35. 1 1
      ui/package.json
  36. 2 2
      ui/src/cloudCost/cloudCostDetails.js
  37. 1 1
      ui/src/cloudCostReports.js
  38. 2 3
      ui/src/services/cloudCostDayTotals.js
  39. 3 3
      ui/src/services/cloudCostTop.js
  40. 32 47
      ui/src/util.js

+ 1 - 0
.gitignore

@@ -5,6 +5,7 @@
 ui/.parcel-cache
 ui/.cache
 ui/dist
+ui/.env
 ui/node_modules/
 cmd/costmodel/costmodel
 cmd/costmodel/costmodel-amd64

+ 1 - 0
CONTRIBUTING.md

@@ -32,6 +32,7 @@ Dependencies:
 1. Docker (with `buildx`)
 2. [just](https://github.com/casey/just) (if you don't want to install it , Just read the `justfile` and run the commands manually)
 3. Multi-arch `buildx` builders set up via https://github.com/tonistiigi/binfmt
+4. `manifest-tool` via https://github.com/estesp/manifest-tool
 4. `npm` (if you want to build the UI)
 
 ### Build the backend

+ 1 - 0
MAINTAINERS.md

@@ -7,6 +7,7 @@ Official list of [OpenCost Maintainers](https://github.com/orgs/opencost/teams/o
 | Maintainer | GitHub ID | Affiliation | Email |
 | --------------- | --------- | ----------- | ----------- |
 | Ajay Tripathy | @AjayTripathy | Kubecost | <Ajay@kubecost.com> |
+| Artur Khantimirov | @r2k1 | Microsoft | |
 | Matt Bolt | @​mbolt35 | Kubecost | <matt@kubecost.com> |
 | Matt Ray | @mattray | Kubecost | <mattray@kubecost.com> |
 | Michael Dresser | @michaelmdresser | Kubecost | <michael@kubecost.com> |

+ 1 - 1
Tiltfile

@@ -90,7 +90,7 @@ docker_build(
     only=[
         'dist',
         'nginx.conf',
-        'default.nginx.conf',
+        'default.nginx.conf.template',
         'docker-entrypoint.sh',
     ],
     live_update=[

+ 1 - 1
justfile

@@ -1,6 +1,6 @@
 commonenv := "CGO_ENABLED=0"
 
-version := "dev"
+version := `./tools/image-tag`
 commit := `git rev-parse --short HEAD`
 
 default:

+ 20 - 40
pkg/cloud/azure/authorizer.go

@@ -1,80 +1,60 @@
 package azure
 
 import (
-	"encoding/json"
 	"fmt"
 
-	"github.com/Azure/azure-storage-blob-go/azblob"
+	"github.com/Azure/azure-sdk-for-go/sdk/azcore"
+	"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
 	"github.com/opencost/opencost/pkg/cloud"
+	"github.com/opencost/opencost/pkg/util/json"
 )
 
-const AccessKeyAuthorizerType = "AzureAccessKey"
+const DefaultCredentialAuthorizerType = "AzureDefaultCredential"
 
+// Authorizer configs provide credentials from azidentity to connect to Azure services.
 type Authorizer interface {
 	cloud.Authorizer
-	GetBlobCredentials() (azblob.Credential, error)
+	GetCredential() (azcore.TokenCredential, error)
 }
 
 // SelectAuthorizerByType is an implementation of AuthorizerSelectorFn and acts as a register for Authorizer types
 func SelectAuthorizerByType(typeStr string) (Authorizer, error) {
 	switch typeStr {
-	case AccessKeyAuthorizerType:
-		return &AccessKey{}, nil
+	case DefaultCredentialAuthorizerType:
+		return &DefaultAzureCredentialHolder{}, nil
 	default:
 		return nil, fmt.Errorf("azure: provider authorizer type '%s' is not valid", typeStr)
 	}
 }
 
-type AccessKey struct {
-	AccessKey string `json:"accessKey"`
-	Account   string `json:"account"`
-}
+type DefaultAzureCredentialHolder struct{}
+
+func (dac *DefaultAzureCredentialHolder) MarshalJSON() ([]byte, error) {
+	fmap := make(map[string]any, 1)
+	fmap[cloud.AuthorizerTypeProperty] = DefaultCredentialAuthorizerType
 
-func (ak *AccessKey) MarshalJSON() ([]byte, error) {
-	fmap := make(map[string]any, 3)
-	fmap[cloud.AuthorizerTypeProperty] = AccessKeyAuthorizerType
-	fmap["accessKey"] = ak.AccessKey
-	fmap["account"] = ak.Account
 	return json.Marshal(fmap)
 }
 
-func (ak *AccessKey) Validate() error {
-	if ak.AccessKey == "" {
-		return fmt.Errorf("AccessKey: missing access key")
-	}
-	if ak.Account == "" {
-		return fmt.Errorf("AccessKey: missing account")
-	}
+func (dac *DefaultAzureCredentialHolder) Validate() error {
 	return nil
 }
 
-func (ak *AccessKey) Equals(config cloud.Config) bool {
+func (dac *DefaultAzureCredentialHolder) Equals(config cloud.Config) bool {
 	if config == nil {
 		return false
 	}
-	thatConfig, ok := config.(*AccessKey)
+	_, ok := config.(*DefaultAzureCredentialHolder)
 	if !ok {
 		return false
 	}
-
-	if ak.AccessKey != thatConfig.AccessKey {
-		return false
-	}
-	if ak.Account != thatConfig.Account {
-		return false
-	}
-
 	return true
 }
 
-func (ak *AccessKey) Sanitize() cloud.Config {
-	return &AccessKey{
-		AccessKey: cloud.Redacted,
-		Account:   ak.Account,
-	}
+func (dac *DefaultAzureCredentialHolder) Sanitize() cloud.Config {
+	return &DefaultAzureCredentialHolder{}
 }
 
-func (ak *AccessKey) GetBlobCredentials() (azblob.Credential, error) {
-	// Create a default request pipeline using your storage account name and account key.
-	return azblob.NewSharedKeyCredential(ak.Account, ak.AccessKey)
+func (dac *DefaultAzureCredentialHolder) GetCredential() (azcore.TokenCredential, error) {
+	return azidentity.NewDefaultAzureCredential(nil)
 }

+ 124 - 0
pkg/cloud/azure/storageauthorizer.go

@@ -0,0 +1,124 @@
+package azure
+
+import (
+	"encoding/json"
+	"fmt"
+
+	"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob"
+	"github.com/opencost/opencost/pkg/cloud"
+)
+
+const SharedKeyAuthorizerType = "AzureAccessKey"
+
+// StorageAuthorizer is a service specific Authorizer for Azure Storage, it exists so that we can support existing Shared
+// Key configurations while allowing the Authorizer to have a service agnostic api
+type StorageAuthorizer interface {
+	cloud.Authorizer
+	GetBlobClient(serviceURL string) (*azblob.Client, error)
+}
+
+// SelectStorageAuthorizerByType is an implementation of AuthorizerSelectorFn and acts as a register for Authorizer types
+func SelectStorageAuthorizerByType(typeStr string) (StorageAuthorizer, error) {
+	switch typeStr {
+	case SharedKeyAuthorizerType:
+		return &SharedKeyCredential{}, nil
+	default:
+		authorizer, err := SelectAuthorizerByType(typeStr)
+		if err != nil {
+			return nil, err
+		}
+		return &AuthorizerHolder{authorizer}, nil
+	}
+}
+
+// SharedKeyCredential is a StorageAuthorizer with credentials which cannot be used to authorize other services. This
+// is a legacy auth method which is not included in azidentity
+type SharedKeyCredential struct {
+	AccessKey string `json:"accessKey"`
+	Account   string `json:"account"`
+}
+
+func (skc *SharedKeyCredential) MarshalJSON() ([]byte, error) {
+	fmap := make(map[string]any, 3)
+	fmap[cloud.AuthorizerTypeProperty] = SharedKeyAuthorizerType
+	fmap["accessKey"] = skc.AccessKey
+	fmap["account"] = skc.Account
+	return json.Marshal(fmap)
+}
+
+func (skc *SharedKeyCredential) Validate() error {
+	if skc.AccessKey == "" {
+		return fmt.Errorf("SharedKeyCredential: missing access key")
+	}
+	if skc.Account == "" {
+		return fmt.Errorf("SharedKeyCredential: missing account")
+	}
+	return nil
+}
+
+func (skc *SharedKeyCredential) Equals(config cloud.Config) bool {
+	if config == nil {
+		return false
+	}
+	thatConfig, ok := config.(*SharedKeyCredential)
+	if !ok {
+		return false
+	}
+
+	if skc.AccessKey != thatConfig.AccessKey {
+		return false
+	}
+	if skc.Account != thatConfig.Account {
+		return false
+	}
+
+	return true
+}
+
+func (skc *SharedKeyCredential) Sanitize() cloud.Config {
+	return &SharedKeyCredential{
+		AccessKey: cloud.Redacted,
+		Account:   skc.Account,
+	}
+}
+
+func (skc *SharedKeyCredential) GetBlobClient(serviceURL string) (*azblob.Client, error) {
+	credential, err := azblob.NewSharedKeyCredential(skc.Account, skc.AccessKey)
+	if err != nil {
+		return nil, err
+	}
+	client, err := azblob.NewClientWithSharedKeyCredential(serviceURL, credential, nil)
+	return client, err
+}
+
+// AuthorizerHolder is a StorageAuthorizer implementation that wraps an Authorizer implementation
+type AuthorizerHolder struct {
+	Authorizer
+}
+
+func (ah *AuthorizerHolder) Equals(config cloud.Config) bool {
+	if config == nil {
+		return false
+	}
+	that, ok := config.(*AuthorizerHolder)
+	if !ok {
+		return false
+	}
+
+	return ah.Authorizer.Equals(that.Authorizer)
+}
+
+func (ah *AuthorizerHolder) Sanitize() cloud.Config {
+	return &AuthorizerHolder{Authorizer: ah.Authorizer.Sanitize().(Authorizer)}
+}
+
+func (ah *AuthorizerHolder) GetBlobClient(serviceURL string) (*azblob.Client, error) {
+	// Create a default request pipeline using your storage account name and account key.
+	cred, err := ah.GetCredential()
+	if err != nil {
+		return nil, err
+	}
+
+	client, err := azblob.NewClient(serviceURL, cred, nil)
+	return client, err
+}

+ 27 - 22
pkg/cloud/azure/storagebillingparser.go

@@ -9,7 +9,8 @@ import (
 	"strings"
 	"time"
 
-	"github.com/Azure/azure-storage-blob-go/azblob"
+	"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob"
+	"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/container"
 	"github.com/opencost/opencost/pkg/cloud"
 	"github.com/opencost/opencost/pkg/log"
 )
@@ -36,13 +37,14 @@ func (asbp *AzureStorageBillingParser) ParseBillingData(start, end time.Time, re
 		return err
 	}
 
-	containerURL, err := asbp.getContainer()
+	serviceURL := fmt.Sprintf(asbp.StorageConnection.getBlobURLTemplate(), asbp.Account, "")
+	client, err := asbp.Authorizer.GetBlobClient(serviceURL)
 	if err != nil {
 		asbp.ConnectionStatus = cloud.FailedConnection
 		return err
 	}
 	ctx := context.Background()
-	blobNames, err := asbp.getMostRecentBlobs(start, end, containerURL, ctx)
+	blobNames, err := asbp.getMostRecentBlobs(start, end, client, ctx)
 	if err != nil {
 		asbp.ConnectionStatus = cloud.FailedConnection
 		return err
@@ -54,7 +56,7 @@ func (asbp *AzureStorageBillingParser) ParseBillingData(start, end time.Time, re
 	}
 
 	for _, blobName := range blobNames {
-		blobBytes, err2 := asbp.DownloadBlob(blobName, containerURL, ctx)
+		blobBytes, err2 := asbp.DownloadBlob(blobName, client, ctx)
 		if err2 != nil {
 			asbp.ConnectionStatus = cloud.FailedConnection
 			return err2
@@ -101,7 +103,7 @@ func (asbp *AzureStorageBillingParser) parseCSV(start, end time.Time, reader *cs
 	return nil
 }
 
-func (asbp *AzureStorageBillingParser) getMostRecentBlobs(start, end time.Time, containerURL *azblob.ContainerURL, ctx context.Context) ([]string, error) {
+func (asbp *AzureStorageBillingParser) getMostRecentBlobs(start, end time.Time, client *azblob.Client, ctx context.Context) ([]string, error) {
 	log.Infof("Azure Storage: retrieving most recent reports from: %v - %v", start, end)
 
 	// Get list of month substrings for months contained in the start to end range
@@ -109,33 +111,36 @@ func (asbp *AzureStorageBillingParser) getMostRecentBlobs(start, end time.Time,
 	if err != nil {
 		return nil, err
 	}
-	mostResentBlobs := make(map[string]azblob.BlobItemInternal)
-	for marker := (azblob.Marker{}); marker.NotDone(); {
-		// Get a result segment starting with the blob indicated by the current Marker.
-		listBlob, err := containerURL.ListBlobsFlatSegment(ctx, marker, azblob.ListBlobsSegmentOptions{})
+	mostResentBlobs := make(map[string]container.BlobItem)
+
+	pager := client.NewListBlobsFlatPager(asbp.Container, &azblob.ListBlobsFlatOptions{
+		Include: container.ListBlobsInclude{Deleted: false, Versions: false},
+	})
+
+	for pager.More() {
+		resp, err := pager.NextPage(ctx)
 		if err != nil {
 			return nil, err
 		}
 
-		// ListBlobs returns the start of the next segment; you MUST use this to get
-		// the next segment (after processing the current result segment).
-		marker = listBlob.NextMarker
-
 		// Using the list of months strings find the most resent blob for each month in the range
-		for _, blobInfo := range listBlob.Segment.BlobItems {
+		for _, blobInfo := range resp.Segment.BlobItems {
+			if blobInfo.Name == nil {
+				continue
+			}
+			// If Container Path configuration exists, check if it is in the blobs name
+			if asbp.Path != "" && !strings.Contains(*blobInfo.Name, asbp.Path) {
+				continue
+			}
 			for _, month := range monthStrs {
-				if strings.Contains(blobInfo.Name, month) {
-					// If Container Path configuration exists, check if it is in the blobs name
-					if asbp.Path != "" && !strings.Contains(blobInfo.Name, asbp.Path) {
-						continue
-					}
-
+				if strings.Contains(*blobInfo.Name, month) {
+					// check if blob is the newest seen for this month
 					if prevBlob, ok := mostResentBlobs[month]; ok {
 						if prevBlob.Properties.CreationTime.After(*blobInfo.Properties.CreationTime) {
 							continue
 						}
 					}
-					mostResentBlobs[month] = blobInfo
+					mostResentBlobs[month] = *blobInfo
 				}
 			}
 		}
@@ -145,7 +150,7 @@ func (asbp *AzureStorageBillingParser) getMostRecentBlobs(start, end time.Time,
 	var blobNames []string
 	for _, month := range monthStrs {
 		if blob, ok := mostResentBlobs[month]; ok {
-			blobNames = append(blobNames, blob.Name)
+			blobNames = append(blobNames, *blob.Name)
 		}
 	}
 

+ 10 - 10
pkg/cloud/azure/storageconfiguration.go

@@ -9,12 +9,12 @@ import (
 )
 
 type StorageConfiguration struct {
-	SubscriptionID string     `json:"subscriptionID"`
-	Account        string     `json:"account"`
-	Container      string     `json:"container"`
-	Path           string     `json:"path"`
-	Cloud          string     `json:"cloud"`
-	Authorizer     Authorizer `json:"authorizer"`
+	SubscriptionID string            `json:"subscriptionID"`
+	Account        string            `json:"account"`
+	Container      string            `json:"container"`
+	Path           string            `json:"path"`
+	Cloud          string            `json:"cloud"`
+	Authorizer     StorageAuthorizer `json:"authorizer"`
 }
 
 // Check ensures that all required fields are set, and throws an error if they are not
@@ -93,7 +93,7 @@ func (sc *StorageConfiguration) Sanitize() cloud.Config {
 		Container:      sc.Container,
 		Path:           sc.Path,
 		Cloud:          sc.Cloud,
-		Authorizer:     sc.Authorizer.Sanitize().(Authorizer),
+		Authorizer:     sc.Authorizer.Sanitize().(StorageAuthorizer),
 	}
 }
 
@@ -153,7 +153,7 @@ func (sc *StorageConfiguration) UnmarshalJSON(b []byte) error {
 	if !ok {
 		return fmt.Errorf("StorageConfiguration: UnmarshalJSON: missing authorizer")
 	}
-	authorizer, err := cloud.AuthorizerFromInterface(authAny, SelectAuthorizerByType)
+	authorizer, err := cloud.AuthorizerFromInterface(authAny, SelectStorageAuthorizerByType)
 	if err != nil {
 		return fmt.Errorf("StorageConfiguration: UnmarshalJSON: %s", err.Error())
 	}
@@ -167,8 +167,8 @@ func ConvertAzureStorageConfigToConfig(asc AzureStorageConfig) cloud.KeyedConfig
 		return nil
 	}
 
-	var authorizer Authorizer
-	authorizer = &AccessKey{
+	var authorizer StorageAuthorizer
+	authorizer = &SharedKeyCredential{
 		AccessKey: asc.AccessKey,
 		Account:   asc.AccountName,
 	}

+ 86 - 28
pkg/cloud/azure/storageconfiguration_test.go

@@ -14,14 +14,14 @@ func TestStorageConfiguration_Validate(t *testing.T) {
 		config   StorageConfiguration
 		expected error
 	}{
-		"valid config Azure AccessKey": {
+		"valid config Azure SharedKeyCredential": {
 			config: StorageConfiguration{
 				SubscriptionID: "subscriptionID",
 				Account:        "account",
 				Container:      "container",
 				Path:           "path",
 				Cloud:          "cloud",
-				Authorizer: &AccessKey{
+				Authorizer: &SharedKeyCredential{
 					AccessKey: "accessKey",
 					Account:   "account",
 				},
@@ -35,11 +35,11 @@ func TestStorageConfiguration_Validate(t *testing.T) {
 				Container:      "container",
 				Path:           "path",
 				Cloud:          "cloud",
-				Authorizer: &AccessKey{
+				Authorizer: &SharedKeyCredential{
 					Account: "account",
 				},
 			},
-			expected: fmt.Errorf("AccessKey: missing access key"),
+			expected: fmt.Errorf("SharedKeyCredential: missing access key"),
 		},
 		"missing authorizer": {
 			config: StorageConfiguration{
@@ -59,7 +59,7 @@ func TestStorageConfiguration_Validate(t *testing.T) {
 				Container:      "container",
 				Path:           "path",
 				Cloud:          "cloud",
-				Authorizer: &AccessKey{
+				Authorizer: &SharedKeyCredential{
 					AccessKey: "accessKey",
 					Account:   "account",
 				},
@@ -73,7 +73,7 @@ func TestStorageConfiguration_Validate(t *testing.T) {
 				Container:      "container",
 				Path:           "path",
 				Cloud:          "cloud",
-				Authorizer: &AccessKey{
+				Authorizer: &SharedKeyCredential{
 					AccessKey: "accessKey",
 					Account:   "account",
 				},
@@ -87,7 +87,7 @@ func TestStorageConfiguration_Validate(t *testing.T) {
 				Container:      "",
 				Path:           "path",
 				Cloud:          "cloud",
-				Authorizer: &AccessKey{
+				Authorizer: &SharedKeyCredential{
 					AccessKey: "accessKey",
 					Account:   "account",
 				},
@@ -101,7 +101,7 @@ func TestStorageConfiguration_Validate(t *testing.T) {
 				Container:      "container",
 				Path:           "",
 				Cloud:          "cloud",
-				Authorizer: &AccessKey{
+				Authorizer: &SharedKeyCredential{
 					AccessKey: "accessKey",
 					Account:   "account",
 				},
@@ -115,7 +115,7 @@ func TestStorageConfiguration_Validate(t *testing.T) {
 				Container:      "container",
 				Path:           "path",
 				Cloud:          "",
-				Authorizer: &AccessKey{
+				Authorizer: &SharedKeyCredential{
 					AccessKey: "accessKey",
 					Account:   "account",
 				},
@@ -155,7 +155,7 @@ func TestStorageConfiguration_Equals(t *testing.T) {
 				Container:      "container",
 				Path:           "path",
 				Cloud:          "cloud",
-				Authorizer: &AccessKey{
+				Authorizer: &SharedKeyCredential{
 					AccessKey: "accessKey",
 					Account:   "account",
 				},
@@ -166,14 +166,36 @@ func TestStorageConfiguration_Equals(t *testing.T) {
 				Container:      "container",
 				Path:           "path",
 				Cloud:          "cloud",
-				Authorizer: &AccessKey{
+				Authorizer: &SharedKeyCredential{
 					AccessKey: "accessKey",
 					Account:   "account",
 				},
 			},
 			expected: true,
 		},
-
+		"matching config AuthorizerHolder": {
+			left: StorageConfiguration{
+				SubscriptionID: "subscriptionID",
+				Account:        "account",
+				Container:      "container",
+				Path:           "path",
+				Cloud:          "cloud",
+				Authorizer: &AuthorizerHolder{
+					Authorizer: &DefaultAzureCredentialHolder{},
+				},
+			},
+			right: &StorageConfiguration{
+				SubscriptionID: "subscriptionID",
+				Account:        "account",
+				Container:      "container",
+				Path:           "path",
+				Cloud:          "cloud",
+				Authorizer: &AuthorizerHolder{
+					Authorizer: &DefaultAzureCredentialHolder{},
+				},
+			},
+			expected: true,
+		},
 		"missing both authorizer": {
 			left: StorageConfiguration{
 				SubscriptionID: "subscriptionID",
@@ -208,7 +230,7 @@ func TestStorageConfiguration_Equals(t *testing.T) {
 				Container:      "container",
 				Path:           "path",
 				Cloud:          "cloud",
-				Authorizer: &AccessKey{
+				Authorizer: &SharedKeyCredential{
 					AccessKey: "accessKey",
 					Account:   "account",
 				},
@@ -222,7 +244,7 @@ func TestStorageConfiguration_Equals(t *testing.T) {
 				Container:      "container",
 				Path:           "path",
 				Cloud:          "cloud",
-				Authorizer: &AccessKey{
+				Authorizer: &SharedKeyCredential{
 					AccessKey: "accessKey",
 					Account:   "account",
 				},
@@ -237,6 +259,30 @@ func TestStorageConfiguration_Equals(t *testing.T) {
 			},
 			expected: false,
 		},
+		"differing storage authorizer": {
+			left: StorageConfiguration{
+				SubscriptionID: "subscriptionID",
+				Account:        "account",
+				Container:      "container",
+				Path:           "path",
+				Cloud:          "cloud",
+				Authorizer: &SharedKeyCredential{
+					AccessKey: "accessKey",
+					Account:   "account",
+				},
+			},
+			right: &StorageConfiguration{
+				SubscriptionID: "subscriptionID",
+				Account:        "account",
+				Container:      "container",
+				Path:           "path",
+				Cloud:          "cloud",
+				Authorizer: &AuthorizerHolder{
+					Authorizer: &DefaultAzureCredentialHolder{},
+				},
+			},
+			expected: false,
+		},
 		"different subscriptionID": {
 			left: StorageConfiguration{
 				SubscriptionID: "subscriptionID",
@@ -244,7 +290,7 @@ func TestStorageConfiguration_Equals(t *testing.T) {
 				Container:      "container",
 				Path:           "path",
 				Cloud:          "cloud",
-				Authorizer: &AccessKey{
+				Authorizer: &SharedKeyCredential{
 					AccessKey: "accessKey",
 					Account:   "account",
 				},
@@ -255,7 +301,7 @@ func TestStorageConfiguration_Equals(t *testing.T) {
 				Container:      "container",
 				Path:           "path",
 				Cloud:          "cloud",
-				Authorizer: &AccessKey{
+				Authorizer: &SharedKeyCredential{
 					AccessKey: "accessKey",
 					Account:   "account",
 				},
@@ -269,7 +315,7 @@ func TestStorageConfiguration_Equals(t *testing.T) {
 				Container:      "container",
 				Path:           "path",
 				Cloud:          "cloud",
-				Authorizer: &AccessKey{
+				Authorizer: &SharedKeyCredential{
 					AccessKey: "accessKey",
 					Account:   "account",
 				},
@@ -280,7 +326,7 @@ func TestStorageConfiguration_Equals(t *testing.T) {
 				Container:      "container",
 				Path:           "path",
 				Cloud:          "cloud",
-				Authorizer: &AccessKey{
+				Authorizer: &SharedKeyCredential{
 					AccessKey: "accessKey",
 					Account:   "account",
 				},
@@ -294,7 +340,7 @@ func TestStorageConfiguration_Equals(t *testing.T) {
 				Container:      "container",
 				Path:           "path",
 				Cloud:          "cloud",
-				Authorizer: &AccessKey{
+				Authorizer: &SharedKeyCredential{
 					AccessKey: "accessKey",
 					Account:   "account",
 				},
@@ -305,7 +351,7 @@ func TestStorageConfiguration_Equals(t *testing.T) {
 				Container:      "container2",
 				Path:           "path",
 				Cloud:          "cloud",
-				Authorizer: &AccessKey{
+				Authorizer: &SharedKeyCredential{
 					AccessKey: "accessKey",
 					Account:   "account",
 				},
@@ -319,7 +365,7 @@ func TestStorageConfiguration_Equals(t *testing.T) {
 				Container:      "container",
 				Path:           "path",
 				Cloud:          "cloud",
-				Authorizer: &AccessKey{
+				Authorizer: &SharedKeyCredential{
 					AccessKey: "accessKey",
 					Account:   "account",
 				},
@@ -330,7 +376,7 @@ func TestStorageConfiguration_Equals(t *testing.T) {
 				Container:      "container",
 				Path:           "path2",
 				Cloud:          "cloud",
-				Authorizer: &AccessKey{
+				Authorizer: &SharedKeyCredential{
 					AccessKey: "accessKey",
 					Account:   "account",
 				},
@@ -344,7 +390,7 @@ func TestStorageConfiguration_Equals(t *testing.T) {
 				Container:      "container",
 				Path:           "path",
 				Cloud:          "cloud",
-				Authorizer: &AccessKey{
+				Authorizer: &SharedKeyCredential{
 					AccessKey: "accessKey",
 					Account:   "account",
 				},
@@ -355,7 +401,7 @@ func TestStorageConfiguration_Equals(t *testing.T) {
 				Container:      "container",
 				Path:           "path",
 				Cloud:          "cloud2",
-				Authorizer: &AccessKey{
+				Authorizer: &SharedKeyCredential{
 					AccessKey: "accessKey",
 					Account:   "account",
 				},
@@ -369,12 +415,12 @@ func TestStorageConfiguration_Equals(t *testing.T) {
 				Container:      "container",
 				Path:           "path",
 				Cloud:          "cloud",
-				Authorizer: &AccessKey{
+				Authorizer: &SharedKeyCredential{
 					AccessKey: "accessKey",
 					Account:   "account",
 				},
 			},
-			right: &AccessKey{
+			right: &SharedKeyCredential{
 				AccessKey: "accessKey",
 				Account:   "account",
 			},
@@ -409,19 +455,31 @@ func TestStorageConfiguration_JSON(t *testing.T) {
 				Authorizer:     nil,
 			},
 		},
-		"AccessKey Authorizer": {
+		"SharedKeyCredential Authorizer": {
 			config: StorageConfiguration{
 				SubscriptionID: "subscriptionID",
 				Account:        "account",
 				Container:      "container",
 				Path:           "path",
 				Cloud:          "cloud",
-				Authorizer: &AccessKey{
+				Authorizer: &SharedKeyCredential{
 					AccessKey: "accessKey",
 					Account:   "account",
 				},
 			},
 		},
+		"Default AuthorizerHolder Authorizer": {
+			config: StorageConfiguration{
+				SubscriptionID: "subscriptionID",
+				Account:        "account",
+				Container:      "container",
+				Path:           "path",
+				Cloud:          "cloud",
+				Authorizer: &AuthorizerHolder{
+					Authorizer: &DefaultAzureCredentialHolder{},
+				},
+			},
+		},
 	}
 
 	for name, testCase := range testCases {

+ 10 - 28
pkg/cloud/azure/storageconnection.go

@@ -4,10 +4,9 @@ import (
 	"bytes"
 	"context"
 	"fmt"
-	"net/url"
 	"strings"
 
-	"github.com/Azure/azure-storage-blob-go/azblob"
+	"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob"
 	"github.com/opencost/opencost/pkg/cloud"
 	"github.com/opencost/opencost/pkg/log"
 )
@@ -35,25 +34,6 @@ func (sc *StorageConnection) Equals(config cloud.Config) bool {
 	return sc.StorageConfiguration.Equals(&thatConfig.StorageConfiguration)
 }
 
-func (sc *StorageConnection) getContainer() (*azblob.ContainerURL, error) {
-
-	credential, err := sc.Authorizer.GetBlobCredentials()
-	if err != nil {
-		return nil, err
-	}
-
-	p := azblob.NewPipeline(credential, azblob.PipelineOptions{})
-
-	// From the Azure portal, get your storage account blob service URL endpoint.
-	URL, _ := url.Parse(
-		fmt.Sprintf(sc.getBlobURLTemplate(), sc.Account, sc.Container))
-
-	// Create a ContainerURL object that wraps the container URL and a request
-	// pipeline to make requests.
-	containerURL := azblob.NewContainerURL(*URL, p)
-	return &containerURL, nil
-}
-
 // getBlobURLTemplate returns the correct BlobUrl for whichever Cloud storage account is specified by the AzureCloud configuration
 // defaults to the Public Cloud template
 func (sc *StorageConnection) getBlobURLTemplate() string {
@@ -65,22 +45,24 @@ func (sc *StorageConnection) getBlobURLTemplate() string {
 	return "https://%s.blob.core.windows.net/%s"
 }
 
-func (sc *StorageConnection) DownloadBlob(blobName string, containerURL *azblob.ContainerURL, ctx context.Context) ([]byte, error) {
+func (sc *StorageConnection) DownloadBlob(blobName string, client *azblob.Client, ctx context.Context) ([]byte, error) {
 	log.Infof("Azure Storage: retrieving blob: %v", blobName)
 
-	blobURL := containerURL.NewBlobURL(blobName)
-	downloadResponse, err := blobURL.Download(ctx, 0, azblob.CountToEnd, azblob.BlobAccessConditions{}, false, azblob.ClientProvidedKeyOptions{})
+	downloadResponse, err := client.DownloadStream(ctx, sc.Container, blobName, nil)
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("Azure: DownloadBlob: failed to download %w", err)
 	}
 	// NOTE: automatically retries are performed if the connection fails
-	bodyStream := downloadResponse.Body(azblob.RetryReaderOptions{MaxRetryRequests: 20})
+	retryReader := downloadResponse.NewRetryReader(ctx, &azblob.RetryReaderOptions{})
+	defer retryReader.Close()
 
 	// read the body into a buffer
 	downloadedData := bytes.Buffer{}
-	_, err = downloadedData.ReadFrom(bodyStream)
+
+	_, err = downloadedData.ReadFrom(retryReader)
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("Azure: DownloadBlob: failed to read downloaded data %w", err)
 	}
+
 	return downloadedData.Bytes(), nil
 }

+ 3 - 3
pkg/cloud/config/configurations_test.go

@@ -31,7 +31,7 @@ var (
 					Container:      "containerName",
 					Path:           "containerPath",
 					Cloud:          "azureCloud",
-					Authorizer: &azure.AccessKey{
+					Authorizer: &azure.SharedKeyCredential{
 						AccessKey: "accessKey",
 						Account:   "accountName",
 					},
@@ -214,11 +214,11 @@ func TestConfigurations_UnmarshalJSON(t *testing.T) {
 		input    any
 		expected *Configurations
 	}{
-		"Azure Storage AccessKey": {
+		"Azure Storage SharedKeyCredential": {
 			input:    azureConfiguration,
 			expected: azureConfiguration,
 		},
-		"Azure Storage AccessKey Conversion": {
+		"Azure Storage SharedKeyCredential Conversion": {
 			input:    azureMultiCloudConf,
 			expected: azureConfiguration,
 		},

+ 7 - 1
pkg/cloud/scaleway/provider.go

@@ -207,7 +207,13 @@ func (key *scalewayPVKey) Features() string {
 
 func (c *Scaleway) GetPVKey(pv *v1.PersistentVolume, parameters map[string]string, defaultRegion string) models.PVKey {
 	// the csi volume handle is the form <az>/<volume-id>
-	zone := strings.Split(pv.Spec.CSI.VolumeHandle, "/")[0]
+	zone := ""
+	if pv.Spec.CSI != nil {
+		zoneVolID := strings.Split(pv.Spec.CSI.VolumeHandle, "/")
+		if len(zoneVolID) > 0 {
+			zone = zoneVolID[0]
+		}
+	}
 	return &scalewayPVKey{
 		Labels:                 pv.Labels,
 		StorageClassName:       pv.Spec.StorageClassName,

+ 3 - 0
pkg/cloudcost/memoryrepository_test.go

@@ -2,6 +2,7 @@ package cloudcost
 
 import (
 	"reflect"
+	"sort"
 	"testing"
 	"time"
 
@@ -172,6 +173,8 @@ func TestMemoryRepository_Keys(t *testing.T) {
 				t.Errorf("Keys() error = %v, wantErr %v", err, tt.wantErr)
 				return
 			}
+			sort.Strings(got)
+			sort.Strings(tt.want)
 			if !reflect.DeepEqual(got, tt.want) {
 				t.Errorf("Keys() got = %v, want %v", got, tt.want)
 			}

+ 1 - 1
pkg/cloudcost/querier.go

@@ -29,7 +29,7 @@ const DefaultChartItemsLength int = 10
 // ViewQuerier defines a contract for return View types to the QueryService to service the View Api
 type ViewQuerier interface {
 	QueryViewGraph(ViewQueryRequest, context.Context) (ViewGraphData, error)
-	QueryViewTotals(ViewQueryRequest, context.Context) (*ViewTableRow, int, error)
+	QueryViewTotals(ViewQueryRequest, context.Context) (*ViewTotals, error)
 	QueryViewTable(ViewQueryRequest, context.Context) (ViewTableRows, error)
 }
 

+ 1 - 11
pkg/cloudcost/queryservice.go

@@ -106,11 +106,6 @@ func (s *QueryService) GetCloudCostViewGraphHandler() func(w http.ResponseWriter
 	}
 }
 
-type CloudCostViewTotalsResponse struct {
-	NumResults int           `json:"numResults"`
-	Combined   *ViewTableRow `json:"combined"`
-}
-
 func (s *QueryService) GetCloudCostViewTotalsHandler() func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
 	// Return valid handler func
 	return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
@@ -136,17 +131,12 @@ func (s *QueryService) GetCloudCostViewTotalsHandler() func(w http.ResponseWrite
 			return
 		}
 
-		totals, count, err := s.ViewQuerier.QueryViewTotals(*request, ctx)
+		resp, err := s.ViewQuerier.QueryViewTotals(*request, ctx)
 		if err != nil {
 			http.Error(w, fmt.Sprintf("Internal server error: %s", err), http.StatusInternalServerError)
 			return
 		}
 
-		resp := CloudCostViewTotalsResponse{
-			NumResults: count,
-			Combined:   totals,
-		}
-
 		_, spanResp := tracer.Start(ctx, "write response")
 		w.Header().Set("Content-Type", "application/json")
 		protocol.WriteData(w, resp)

+ 5 - 0
pkg/cloudcost/queryservice_helper.go

@@ -38,6 +38,11 @@ func ParseCloudCostRequest(qp httputil.QueryParams) (*QueryRequest, error) {
 		aggregateBy = append(aggregateBy, prop)
 	}
 
+	// if we're aggregating by nothing (aka `item` on the frontend) then aggregate by all
+	if len(aggregateBy) == 0 {
+		aggregateBy = []string{kubecost.CloudCostInvoiceEntityIDProp, kubecost.CloudCostAccountIDProp, kubecost.CloudCostProviderProp, kubecost.CloudCostProviderIDProp, kubecost.CloudCostCategoryProp, kubecost.CloudCostServiceProp}
+	}
+
 	accumulate := kubecost.ParseAccumulate(qp.Get("accumulate", ""))
 
 	var filter filter21.Filter

+ 4 - 4
pkg/cloudcost/queryservice_helper_test.go

@@ -41,7 +41,7 @@ func TestParseCloudCostRequest(t *testing.T) {
 			want: &QueryRequest{
 				Start:       start,
 				End:         end,
-				AggregateBy: nil,
+				AggregateBy: []string{kubecost.CloudCostInvoiceEntityIDProp, kubecost.CloudCostAccountIDProp, kubecost.CloudCostProviderProp, kubecost.CloudCostProviderIDProp, kubecost.CloudCostCategoryProp, kubecost.CloudCostServiceProp},
 				Accumulate:  "",
 				Filter:      nil,
 			},
@@ -77,7 +77,7 @@ func TestParseCloudCostRequest(t *testing.T) {
 			want: &QueryRequest{
 				Start:       start,
 				End:         end,
-				AggregateBy: nil,
+				AggregateBy: []string{kubecost.CloudCostInvoiceEntityIDProp, kubecost.CloudCostAccountIDProp, kubecost.CloudCostProviderProp, kubecost.CloudCostProviderIDProp, kubecost.CloudCostCategoryProp, kubecost.CloudCostServiceProp},
 				Accumulate:  kubecost.AccumulateOptionWeek,
 				Filter:      nil,
 			},
@@ -91,7 +91,7 @@ func TestParseCloudCostRequest(t *testing.T) {
 			want: &QueryRequest{
 				Start:       start,
 				End:         end,
-				AggregateBy: nil,
+				AggregateBy: []string{kubecost.CloudCostInvoiceEntityIDProp, kubecost.CloudCostAccountIDProp, kubecost.CloudCostProviderProp, kubecost.CloudCostProviderIDProp, kubecost.CloudCostCategoryProp, kubecost.CloudCostServiceProp},
 				Accumulate:  kubecost.AccumulateOptionNone,
 				Filter:      nil,
 			},
@@ -105,7 +105,7 @@ func TestParseCloudCostRequest(t *testing.T) {
 			want: &QueryRequest{
 				Start:       start,
 				End:         end,
-				AggregateBy: nil,
+				AggregateBy: []string{kubecost.CloudCostInvoiceEntityIDProp, kubecost.CloudCostAccountIDProp, kubecost.CloudCostProviderProp, kubecost.CloudCostProviderIDProp, kubecost.CloudCostCategoryProp, kubecost.CloudCostServiceProp},
 				Accumulate:  kubecost.AccumulateOptionNone,
 				Filter:      validFilter,
 			},

+ 23 - 14
pkg/cloudcost/repositoryquerier.go

@@ -114,42 +114,45 @@ func (rq *RepositoryQuerier) QueryViewGraph(request ViewQueryRequest, ctx contex
 	return sets, nil
 }
 
-func (rq *RepositoryQuerier) QueryViewTotals(request ViewQueryRequest, ctx context.Context) (*ViewTableRow, int, error) {
+func (rq *RepositoryQuerier) QueryViewTotals(request ViewQueryRequest, ctx context.Context) (*ViewTotals, error) {
 	ccasr, err := rq.Query(request.QueryRequest, ctx)
 	if err != nil {
-		return nil, -1, fmt.Errorf("QueryViewTotals: query failed: %w", err)
+		return nil, fmt.Errorf("QueryViewTotals: query failed: %w", err)
 	}
 	acc, err := ccasr.AccumulateAll()
 	if err != nil {
-		return nil, -1, fmt.Errorf("QueryViewTotals: accumulate failed: %w", err)
+		return nil, fmt.Errorf("QueryViewTotals: accumulate failed: %w", err)
 	}
 	if acc.IsEmpty() {
-		return nil, 0, nil
+		return nil, nil
 	}
 	count := len(acc.CloudCosts)
 
 	total, err := acc.Aggregate([]string{})
 	if err != nil {
-		return nil, -1, fmt.Errorf("QueryViewTotals: aggregate total failed: %w", err)
+		return nil, fmt.Errorf("QueryViewTotals: aggregate total failed: %w", err)
 	}
 
 	if total.IsEmpty() {
-		return nil, -1, fmt.Errorf("QueryViewTotals: missing total: %w", err)
+		return nil, fmt.Errorf("QueryViewTotals: missing total: %w", err)
 	}
 
 	if len(total.CloudCosts) != 1 {
-		return nil, -1, fmt.Errorf("QueryViewTotals: total did not aggregate: %w", err)
+		return nil, fmt.Errorf("QueryViewTotals: total did not aggregate: %w", err)
 	}
 
 	cm, err := total.CloudCosts[""].GetCostMetric(request.CostMetricName)
 	if err != nil {
-		return nil, -1, fmt.Errorf("QueryViewTotals: failed to retrieve cost metric: %w", err)
-	}
-	return &ViewTableRow{
-		Name:              "Totals",
-		KubernetesPercent: cm.KubernetesPercent,
-		Cost:              cm.Cost,
-	}, count, nil
+		return nil, fmt.Errorf("QueryViewTotals: failed to retrieve cost metric: %w", err)
+	}
+	return &ViewTotals{
+		NumResults: count,
+		Combined: &ViewTableRow{
+			Name:              "Totals",
+			KubernetesPercent: cm.KubernetesPercent,
+			Cost:              cm.Cost,
+		},
+	}, nil
 }
 
 func (rq *RepositoryQuerier) QueryViewTable(request ViewQueryRequest, ctx context.Context) (ViewTableRows, error) {
@@ -168,8 +171,14 @@ func (rq *RepositoryQuerier) QueryViewTable(request ViewQueryRequest, ctx contex
 		if err2 != nil {
 			return nil, fmt.Errorf("QueryViewTable: failed to retrieve cost metric: %w", err)
 		}
+		var labels map[string]string
+		if cloudCost.Properties != nil {
+			labels = cloudCost.Properties.Labels
+		}
+
 		vtr := &ViewTableRow{
 			Name:              key,
+			Labels:            labels,
 			KubernetesPercent: costMetric.KubernetesPercent,
 			Cost:              costMetric.Cost,
 		}

+ 23 - 3
pkg/cloudcost/view.go

@@ -31,9 +31,10 @@ func (vtrs ViewTableRows) Equal(that ViewTableRows) bool {
 }
 
 type ViewTableRow struct {
-	Name              string  `json:"name"`
-	KubernetesPercent float64 `json:"kubernetesPercent"`
-	Cost              float64 `json:"cost"`
+	Name              string            `json:"name"`
+	Labels            map[string]string `json:"labels"`
+	KubernetesPercent float64           `json:"kubernetesPercent"`
+	Cost              float64           `json:"cost"`
 }
 
 func (vtr *ViewTableRow) Equal(that *ViewTableRow) bool {
@@ -41,6 +42,20 @@ func (vtr *ViewTableRow) Equal(that *ViewTableRow) bool {
 		return false
 	}
 
+	if len(vtr.Labels) != len(that.Labels) {
+		return false
+	}
+
+	for key, value := range vtr.Labels {
+		thatValue, ok := that.Labels[key]
+		if !ok {
+			return false
+		}
+		if value != thatValue {
+			return false
+		}
+	}
+
 	if !mathutil.Approximately(vtr.KubernetesPercent, that.KubernetesPercent) {
 		return false
 	}
@@ -105,3 +120,8 @@ func (vgdsi ViewGraphDataSetItem) Equal(that ViewGraphDataSetItem) bool {
 
 	return true
 }
+
+type ViewTotals struct {
+	NumResults int           `json:"numResults"`
+	Combined   *ViewTableRow `json:"combined"`
+}

+ 12 - 1
pkg/cmd/costmodel/costmodel.go

@@ -4,6 +4,7 @@ import (
 	"context"
 	"fmt"
 	"net/http"
+	"net/http/pprof"
 	"time"
 
 	"github.com/julienschmidt/httprouter"
@@ -62,12 +63,22 @@ func Execute(opts *CostModelOpts) error {
 	a.Router.GET("/cloudCost/rebuild", a.CloudCostPipelineService.GetCloudCostRebuildHandler())
 	a.Router.GET("/cloudCost/repair", a.CloudCostPipelineService.GetCloudCostRepairHandler())
 
+	if env.IsPProfEnabled() {
+		a.Router.HandlerFunc(http.MethodGet, "/debug/pprof/", pprof.Index)
+		a.Router.HandlerFunc(http.MethodGet, "/debug/pprof/cmdline", pprof.Cmdline)
+		a.Router.HandlerFunc(http.MethodGet, "/debug/pprof/profile", pprof.Profile)
+		a.Router.HandlerFunc(http.MethodGet, "/debug/pprof/symbol", pprof.Symbol)
+		a.Router.HandlerFunc(http.MethodGet, "/debug/pprof/trace", pprof.Trace)
+		a.Router.Handler(http.MethodGet, "/debug/pprof/goroutine", pprof.Handler("goroutine"))
+		a.Router.Handler(http.MethodGet, "/debug/pprof/heap", pprof.Handler("heap"))
+	}
+
 	rootMux.Handle("/", a.Router)
 	rootMux.Handle("/metrics", promhttp.Handler())
 	telemetryHandler := metrics.ResponseMetricMiddleware(rootMux)
 	handler := cors.AllowAll().Handler(telemetryHandler)
 
-	return http.ListenAndServe(":9003", errors.PanicHandlerMiddleware(handler))
+	return http.ListenAndServe(fmt.Sprint(":", env.GetAPIPort()), errors.PanicHandlerMiddleware(handler))
 }
 
 func StartExportWorker(ctx context.Context, model costmodel.AllocationModel) error {

+ 5 - 7
pkg/costmodel/cluster_helpers.go

@@ -636,13 +636,11 @@ func buildLabelsMap(
 			Name:    node,
 		}
 
-		m[key] = make(map[string]string)
-
-		for name, value := range result.Metric {
-			if val, ok := value.(string); ok {
-				m[key][name] = val
-			}
-		}
+		// The QueryResult.GetLabels function needs to be called to sanitize the
+		// ingested label data. This removes the label_ prefix that prometheus
+		// adds to emitted labels. It also keeps from ingesting prometheus labels
+		// that aren't a part of the asset.
+		m[key] = result.GetLabels()
 	}
 	return m
 }

+ 72 - 0
pkg/costmodel/cluster_helpers_test.go

@@ -2,6 +2,7 @@ package costmodel
 
 import (
 	"reflect"
+	"strings"
 	"testing"
 	"time"
 
@@ -1108,3 +1109,74 @@ func TestAssetCustompricing(t *testing.T) {
 	}
 
 }
+
+func TestBuildLabelsMap(t *testing.T) {
+	const (
+		labelKey1   = "testlabelkey1"
+		labelValue1 = "testlabel1-value"
+		labelKey2   = "test-label-key-2"
+		labelValue2 = "testlabel2.value"
+		nonLabelKey = "instance_type"
+		labelPrefix = "label_"
+	)
+
+	startTimestamp := float64(windowStart.Unix())
+
+	nodePromResult := []*prom.QueryResult{
+		{
+			Metric: map[string]interface{}{
+				"cluster_id":             "cluster1",
+				"node":                   "node1",
+				"instance_type":          "type1",
+				"provider_id":            "provider1",
+				"label_testlabelkey1":    "testlabel1-value",
+				"label_test-label-key-2": "testlabel2.value",
+			},
+			Values: []*util.Vector{
+				{
+					Timestamp: startTimestamp,
+					Value:     0.5,
+				},
+			},
+		},
+		{
+			Metric: map[string]interface{}{
+				"cluster_id":             "cluster1",
+				"node":                   "node2",
+				"instance_type":          "type1",
+				"provider_id":            "provider1",
+				"label_testlabelkey1":    "testlabel1-value",
+				"label_test-label-key-2": "testlabel2.value",
+			},
+			Values: []*util.Vector{
+				{
+					Timestamp: startTimestamp,
+					Value:     0.5,
+				},
+			},
+		},
+	}
+
+	nodeLabelMap := buildLabelsMap(nodePromResult)
+	// Test that for all nodes and all label keys in the map there isn't a key with the label_ prefix.
+	for _, labelMap := range nodeLabelMap {
+		for key, value := range labelMap {
+			if strings.HasPrefix(key, labelPrefix) {
+				t.Errorf("Asset label maps aren't sanitized. Expected no '%v' prefix in %v", labelPrefix, key)
+			}
+			// Test that the label value isn't touched
+			if key == labelKey1 && value != labelValue1 {
+				t.Errorf("Label Value didn't match. Got %v, but Expected: %v", value, labelValue1)
+			}
+			// Test that the label value isn't touched
+			if key == labelKey2 && value != labelValue2 {
+				t.Errorf("Label Value didn't match. Got %v, but Expected: %v", value, labelValue2)
+			}
+		}
+		// Test that keys that don't have the label_ prefix aren't in the resultant label map.
+		_, ok := labelMap[nonLabelKey]
+		if ok {
+			t.Errorf("Non-label keys are included in label mapping for asset labels. Expected '%v' to not exist'.", nonLabelKey)
+		}
+	}
+}

+ 61 - 10
pkg/costmodel/costmodel.go

@@ -31,6 +31,8 @@ const (
 
 	profileThreshold = 1000 * 1000 * 1000 // 1s (in ns)
 
+	unmountedPVsContainer = "unmounted-pvs"
+
 	apiPrefix         = "/api/v1"
 	epAlertManagers   = apiPrefix + "/alertmanagers"
 	epLabelValues     = apiPrefix + "/label/:name/values"
@@ -724,16 +726,13 @@ func findUnmountedPVCostData(clusterMap clusters.ClusterMap, unmountedPVs map[st
 
 		namespaceAnnotations, _ := namespaceAnnotationsMapping[ns+","+clusterID]
 
-		// Should be a unique "Unmounted" cost data type
-		name := "unmounted-pvs"
-
-		metric := NewContainerMetricFromValues(ns, name, name, "", clusterID)
+		metric := NewContainerMetricFromValues(ns, unmountedPVsContainer, unmountedPVsContainer, "", clusterID)
 		key := metric.Key()
 
 		if costData, ok := costs[key]; !ok {
 			costs[key] = &CostData{
-				Name:            name,
-				PodName:         name,
+				Name:            unmountedPVsContainer,
+				PodName:         unmountedPVsContainer,
 				NodeName:        "",
 				Annotations:     namespaceAnnotations,
 				Namespace:       ns,
@@ -1115,7 +1114,60 @@ func (cm *CostModel) GetNodeCost(cp costAnalyzerCloud.Provider) (map[string]*cos
 			gpuc = 0.0
 		}
 
-		if newCnode.GPU != "" && newCnode.GPUCost == "" {
+		// Special case for SUSE rancher, since it won't behave with normal
+		// calculations, courtesy of the instance type not being "real" (a
+		// recognizable AWS instance type.)
+		if newCnode.InstanceType == "rke2" {
+			log.Infof(
+				"Found a SUSE Rancher node %s, defaulting and skipping math",
+				cp.GetKey(nodeLabels, n).Features(),
+			)
+
+			defaultCPUCorePrice, err := strconv.ParseFloat(cfg.CPU, 64)
+			if err != nil {
+				log.Errorf("Could not parse default cpu price")
+				defaultCPUCorePrice = 0
+			}
+			if math.IsNaN(defaultCPUCorePrice) {
+				log.Warnf("defaultCPU parsed as NaN. Setting to 0.")
+				defaultCPUCorePrice = 0
+			}
+
+			defaultRAMPrice, err := strconv.ParseFloat(cfg.RAM, 64)
+			if err != nil {
+				log.Errorf("Could not parse default ram price")
+				defaultRAMPrice = 0
+			}
+			if math.IsNaN(defaultRAMPrice) {
+				log.Warnf("defaultRAM parsed as NaN. Setting to 0.")
+				defaultRAMPrice = 0
+			}
+
+			defaultGPUPrice, err := strconv.ParseFloat(cfg.GPU, 64)
+			if err != nil {
+				log.Errorf("Could not parse default gpu price")
+				defaultGPUPrice = 0
+			}
+			if math.IsNaN(defaultGPUPrice) {
+				log.Warnf("defaultGPU parsed as NaN. Setting to 0.")
+				defaultGPUPrice = 0
+			}
+			// Just say no to doing the ratios!
+			cpuCost := defaultCPUCorePrice * cpu
+			gpuCost := defaultGPUPrice * gpuc
+			ramCost := defaultRAMPrice * ram
+			nodeCost := cpuCost + gpuCost + ramCost
+
+			newCnode.Cost = fmt.Sprintf("%f", nodeCost)
+			newCnode.VCPUCost = fmt.Sprintf("%f", cpuCost)
+			newCnode.GPUCost = fmt.Sprintf("%f", gpuCost)
+			newCnode.RAMCost = fmt.Sprintf("%f", ramCost)
+			newCnode.RAMBytes = fmt.Sprintf("%f", ram)
+
+		} else if newCnode.GPU != "" && newCnode.GPUCost == "" {
+			// was the big thing to investigate. All the funky ratio math
+			// we were doing was messing with their default pricing. for SUSE Rancher.
+
 			// We couldn't find a gpu cost, so fix cpu and ram, then accordingly
 			log.Infof("GPU without cost found for %s, calculating...", cp.GetKey(nodeLabels, n).Features())
 
@@ -2502,9 +2554,8 @@ func (cm *CostModel) QueryAllocation(window kubecost.Window, resolution, step ti
 					}
 
 					if totals == nil {
-						log.Errorf("unable to locate asset totals for allocation %s", key)
-						return nil, fmt.Errorf("unable to locate allocation totals for allocation")
-
+						log.Errorf("unable to locate asset totals for allocation %s, corresponding PARC is being skipped", key)
+						continue
 					}
 
 					parc.CPUTotalCost = totals.CPUCost

+ 45 - 38
pkg/costmodel/metrics.go

@@ -421,7 +421,7 @@ func (cmme *CostModelMetricsEmitter) Start() bool {
 			var ok bool
 			defaultRegion, ok = util.GetRegion(nodeList[0].Labels)
 			if !ok {
-				log.DedupedWarningf(5, "Failed to locate default region")
+				log.DedupedWarningf(5, "Failed to read default region from labels on node %s", nodeList[0].Name)
 			}
 		}
 
@@ -471,7 +471,7 @@ func (cmme *CostModelMetricsEmitter) Start() bool {
 			// TODO: Pass CloudProvider into CostModel on instantiation so this isn't so awkward
 			nodes, err := cmme.Model.GetNodeCost(cmme.CloudProvider)
 			if err != nil {
-				log.Warnf("Metric emission: error getting Node cost: %s", err)
+				log.Warnf("Error getting Node cost: %s", err)
 			}
 			for nodeName, node := range nodes {
 				// Emit costs, guarding against NaN inputs for custom pricing.
@@ -535,24 +535,27 @@ func (cmme *CostModelMetricsEmitter) Start() bool {
 				// don't record cpuCost, ramCost, or gpuCost in the case of wild outliers
 				// k8s api sometimes causes cost spikes as described here:
 				// https://github.com/opencost/opencost/issues/927
-				if cpuCost < outlierFactor*avgCosts.CpuCostAverage {
+				cpuOutlierCutoff := outlierFactor * avgCosts.CpuCostAverage
+				if cpuCost < cpuOutlierCutoff {
 					cmme.CPUPriceRecorder.WithLabelValues(nodeName, nodeName, nodeType, nodeRegion, node.ProviderID, node.ArchType).Set(cpuCost)
 					avgCosts.CpuCostAverage = (avgCosts.CpuCostAverage*avgCosts.NumCpuDataPoints + cpuCost) / (avgCosts.NumCpuDataPoints + 1)
 					avgCosts.NumCpuDataPoints += 1
 				} else {
-					log.Warnf("CPU cost outlier detected; skipping data point.")
+					log.Debugf("CPU cost outlier detected; skipping data point: %s had %f as cost, which is above %f.", nodeName, cpuCost, cpuOutlierCutoff)
 				}
-				if ramCost < outlierFactor*avgCosts.RamCostAverage {
+				ramOutlierCutoff := outlierFactor * avgCosts.RamCostAverage
+				if ramCost < ramOutlierCutoff {
 					cmme.RAMPriceRecorder.WithLabelValues(nodeName, nodeName, nodeType, nodeRegion, node.ProviderID, node.ArchType).Set(ramCost)
 					avgCosts.RamCostAverage = (avgCosts.RamCostAverage*avgCosts.NumRamDataPoints + ramCost) / (avgCosts.NumRamDataPoints + 1)
 					avgCosts.NumRamDataPoints += 1
 				} else {
-					log.Warnf("RAM cost outlier detected; skipping data point.")
+					log.Debugf("RAM cost outlier detected; skipping data point: %s had %f as cost, which is above %f.", nodeName, ramCost, ramOutlierCutoff)
 				}
 				// skip redording totalCost if any constituent costs were outliers
-				if cpuCost < outlierFactor*avgCosts.CpuCostAverage &&
-					ramCost < outlierFactor*avgCosts.RamCostAverage {
+				if cpuCost < cpuOutlierCutoff && ramCost < ramOutlierCutoff {
 					cmme.NodeTotalPriceRecorder.WithLabelValues(nodeName, nodeName, nodeType, nodeRegion, node.ProviderID, node.ArchType).Set(totalCost)
+				} else {
+					log.Debugf("CPU and RAM outlier detected, not recording node %s total cost %f", nodeName, totalCost)
 				}
 
 				nodeCostAverages[labelKey] = avgCosts
@@ -568,7 +571,7 @@ func (cmme *CostModelMetricsEmitter) Start() bool {
 			// TODO: Pass CloudProvider into CostModel on instantiation so this isn't so awkward
 			loadBalancers, err := cmme.Model.GetLBCost(cmme.CloudProvider)
 			if err != nil {
-				log.Warnf("Metric emission: error getting LoadBalancer cost: %s", err)
+				log.Warnf("Error getting LoadBalancer cost: %s", err)
 			}
 			for lbKey, lb := range loadBalancers {
 				// TODO: parse (if necessary) and calculate cost associated with loadBalancer based on dynamic cloud prices fetched into each lb struct on GetLBCost() call
@@ -644,7 +647,7 @@ func (cmme *CostModelMetricsEmitter) Start() bool {
 
 				parameters, ok := storageClassMap[pv.Spec.StorageClassName]
 				if !ok {
-					log.Debugf("Unable to find parameters for storage class \"%s\". Does pv \"%s\" have a storageClassName?", pv.Spec.StorageClassName, pv.Name)
+					log.Debugf("Unable to find parameters for storage class \"%s\". Pv \"%s\" might have an empty or invalid storageClassName.", pv.Spec.StorageClassName, pv.Name)
 				}
 				var region string
 				if r, ok := util.GetRegion(pv.Labels); ok {
@@ -662,7 +665,7 @@ func (cmme *CostModelMetricsEmitter) Start() bool {
 				GetPVCost(cacPv, pv, cmme.CloudProvider, region)
 				c, _ := strconv.ParseFloat(cacPv.Cost, 64)
 				cmme.PersistentVolumePriceRecorder.WithLabelValues(pv.Name, pv.Name, cacPv.ProviderID).Set(c)
-				labelKey := getKeyFromLabelStrings(pv.Name, pv.Name)
+				labelKey := getKeyFromLabelStrings(pv.Name, pv.Name, cacPv.ProviderID)
 				pvSeen[labelKey] = true
 			}
 
@@ -670,44 +673,44 @@ func (cmme *CostModelMetricsEmitter) Start() bool {
 			// longer exist
 			for labelString, seen := range nodeSeen {
 				if !seen {
-					log.Debugf("Removing %s from nodes", labelString)
+					log.Debugf("Removing metrics for %s, no data observed recently", labelString)
 					labels := getLabelStringsFromKey(labelString)
 
 					ok := cmme.NodeTotalPriceRecorder.DeleteLabelValues(labels...)
 					if ok {
-						log.Debugf("removed %s from totalprice", labelString)
+						log.Debugf("No data observed for node with labels %v, removed from totalprice", labels)
 					} else {
-						log.Errorf("FAILURE TO REMOVE %s from totalprice", labelString)
+						log.Warnf("Failed to remove label set %v from metric node_total_hourly_cost. Failure to remove stale metrics may result in inaccurate data.", labels)
 					}
 					ok = cmme.NodeSpotRecorder.DeleteLabelValues(labels...)
 					if ok {
-						log.Debugf("removed %s from spot records", labelString)
+						log.Debugf("No data observed for node with labels %v, removed from spot records", labels)
 					} else {
-						log.Errorf("FAILURE TO REMOVE %s from spot records", labelString)
+						log.Warnf("Failed to remove label set %v from metric kubecost_node_is_spot. Failure to remove stale metrics may result in inaccurate data.", labels)
 					}
 					ok = cmme.CPUPriceRecorder.DeleteLabelValues(labels...)
 					if ok {
-						log.Debugf("removed %s from cpuprice", labelString)
+						log.Debugf("No data observed for node with labels %v, removed from cpuprice", labels)
 					} else {
-						log.Errorf("FAILURE TO REMOVE %s from cpuprice", labelString)
+						log.Warnf("Failed to remove label set %v from metric node_cpu_hourly_cost. Failure to remove stale metrics may result in inaccurate data.", labels)
 					}
 					ok = cmme.GPUPriceRecorder.DeleteLabelValues(labels...)
 					if ok {
-						log.Debugf("removed %s from gpuprice", labelString)
+						log.Debugf("No data observed for node with labels %v, removed from gpuprice", labels)
 					} else {
-						log.Errorf("FAILURE TO REMOVE %s from gpuprice", labelString)
+						log.Warnf("Failed to remove label set %v from metric node_gpu_hourly_cost. Failure to remove stale metrics may result in inaccurate data.", labels)
 					}
 					ok = cmme.GPUCountRecorder.DeleteLabelValues(labels...)
 					if ok {
-						log.Debugf("removed %s from gpucount", labelString)
+						log.Debugf("No data observed for node with labels %v, removed from gpucount", labels)
 					} else {
-						log.Errorf("FAILURE TO REMOVE %s from gpucount", labelString)
+						log.Warnf("Failed to remove label set %v from metric node_gpu_count. Failure to remove stale metrics may result in inaccurate data.", labels)
 					}
 					ok = cmme.RAMPriceRecorder.DeleteLabelValues(labels...)
 					if ok {
-						log.Debugf("removed %s from ramprice", labelString)
+						log.Debugf("No data observed for node with labels %v, removed from ramprice", labels)
 					} else {
-						log.Errorf("FAILURE TO REMOVE %s from ramprice", labelString)
+						log.Warnf("Failed to remove label set %v from metric node_ram_hourly_cost. Failure to remove stale metrics may result in inaccurate data.", labels)
 					}
 					delete(nodeSeen, labelString)
 					delete(nodeCostAverages, labelString)
@@ -720,7 +723,7 @@ func (cmme *CostModelMetricsEmitter) Start() bool {
 					labels := getLabelStringsFromKey(labelString)
 					ok := cmme.LBCostRecorder.DeleteLabelValues(labels...)
 					if !ok {
-						log.Errorf("Metric emission: failed to delete LoadBalancer with labels: %v", labels)
+						log.Warnf("Failed to remove label set %v from metric kubecost_load_balancer_cost. Failure to remove stale metrics may result in inaccurate data.", labels)
 					}
 					delete(loadBalancerSeen, labelString)
 				} else {
@@ -730,17 +733,21 @@ func (cmme *CostModelMetricsEmitter) Start() bool {
 			for labelString, seen := range containerSeen {
 				if !seen {
 					labels := getLabelStringsFromKey(labelString)
-					ok := cmme.RAMAllocationRecorder.DeleteLabelValues(labels...)
-					if !ok {
-						log.Errorf("Metric emission: failed to delete RAMAllocation with labels: %v", labels)
-					}
-					ok = cmme.CPUAllocationRecorder.DeleteLabelValues(labels...)
-					if !ok {
-						log.Errorf("Metric emission: failed to delete CPUAllocation with labels: %v", labels)
-					}
-					ok = cmme.GPUAllocationRecorder.DeleteLabelValues(labels...)
-					if !ok {
-						log.Errorf("Metric emission: failed to delete GPUAllocation with labels: %v", labels)
+					if len(labels) >= 2 && labels[1] != unmountedPVsContainer { // special "pod" to contain the unmounted PVs - does not have RAM/CPU/...
+						ok := cmme.RAMAllocationRecorder.DeleteLabelValues(labels...)
+						if !ok {
+							log.Warnf("Failed to remove label set %v from metric container_memory_allocation_bytes. Failure to remove stale metrics may result in inaccurate data.", labels)
+						}
+						ok = cmme.CPUAllocationRecorder.DeleteLabelValues(labels...)
+						if !ok {
+							log.Warnf("Failed to remove label set %v from metric container_cpu_allocation. Failure to remove stale metrics may result in inaccurate data.", labels)
+						}
+						ok = cmme.GPUAllocationRecorder.DeleteLabelValues(labels...)
+						if !ok {
+							log.Warnf("Failed to remove label set %v from metric container_gpu_allocation. Failure to remove stale metrics may result in inaccurate data.", labels)
+						}
+					} else {
+						log.Debugf("Did not try to delete RAM/CPU/GPU for fake '%s' container: %v", unmountedPVsContainer, labels)
 					}
 					delete(containerSeen, labelString)
 				} else {
@@ -752,7 +759,7 @@ func (cmme *CostModelMetricsEmitter) Start() bool {
 					labels := getLabelStringsFromKey(labelString)
 					ok := cmme.PersistentVolumePriceRecorder.DeleteLabelValues(labels...)
 					if !ok {
-						log.Errorf("Metric emission: failed to delete PVPrice with labels: %v", labels)
+						log.Warnf("Failed to remove label set %v from metric pv_hourly_cost. Failure to remove stale metrics may result in inaccurate data.", labels)
 					}
 					delete(pvSeen, labelString)
 				} else {
@@ -764,7 +771,7 @@ func (cmme *CostModelMetricsEmitter) Start() bool {
 					labels := getLabelStringsFromKey(labelString)
 					ok := cmme.PVAllocationRecorder.DeleteLabelValues(labels...)
 					if !ok {
-						log.Errorf("Metric emission: failed to delete PVAllocation with labels: %v", labels)
+						log.Warnf("Failed to remove label set %v from metric pod_pvc_allocation. Failure to remove stale metrics may result in inaccurate data.", labels)
 					}
 					delete(pvcSeen, labelString)
 				} else {

+ 14 - 0
pkg/env/costmodelenv.go

@@ -11,6 +11,8 @@ import (
 )
 
 const (
+	APIPortEnvVar = "API_PORT"
+
 	AWSAccessKeyIDEnvVar     = "AWS_ACCESS_KEY_ID"
 	AWSAccessKeySecretEnvVar = "AWS_SECRET_ACCESS_KEY"
 	AWSClusterIDEnvVar       = "AWS_CLUSTER_ID"
@@ -51,6 +53,8 @@ const (
 	ThanosOffsetEnvVar       = "THANOS_QUERY_OFFSET"
 	ThanosMaxSourceResEnvVar = "THANOS_MAX_SOURCE_RESOLUTION"
 
+	PProfEnabledEnvVar = "PPROF_ENABLED"
+
 	LogCollectionEnabledEnvVar    = "LOG_COLLECTION_ENABLED"
 	ProductAnalyticsEnabledEnvVar = "PRODUCT_ANALYTICS_ENABLED"
 	ErrorReportingEnabledEnvVar   = "ERROR_REPORTING_ENABLED"
@@ -137,10 +141,20 @@ func GetExportCSVLabelsList() []string {
 	return GetList(ExportCSVLabelsList, ",")
 }
 
+func IsPProfEnabled() bool {
+	return GetBool(PProfEnabledEnvVar, false)
+}
+
 func GetExportCSVMaxDays() int {
 	return GetInt(ExportCSVMaxDays, 90)
 }
 
+// GetAPIPort returns the environment variable value for APIPortEnvVar which
+// is the port number the API is available on.
+func GetAPIPort() int {
+	return GetInt(APIPortEnvVar, 9003)
+}
+
 // GetKubecostConfigBucket returns a file location for a mounted bucket configuration which is used to store
 // a subset of kubecost configurations that require sharing via remote storage.
 func GetKubecostConfigBucket() string {

+ 38 - 0
pkg/env/costmodelenv_test.go

@@ -5,6 +5,44 @@ import (
 	"testing"
 )
 
+func TestGetAPIPort(t *testing.T) {
+	tests := []struct {
+		name string
+		want int
+		pre  func()
+	}{
+		{
+			name: "Ensure the default API port '9003'",
+			want: 9003,
+		},
+		{
+			name: "Ensure the default API port '9003' when API_PORT is set to ''",
+			want: 9003,
+			pre: func() {
+				os.Setenv("API_PORT", "")
+			},
+		},
+		{
+			name: "Ensure the API port '9004' when API_PORT is set to '9004'",
+			want: 9004,
+			pre: func() {
+				os.Setenv("API_PORT", "9004")
+			},
+		},
+	}
+	for _, tt := range tests {
+		if tt.pre != nil {
+			tt.pre()
+		}
+		t.Run(tt.name, func(t *testing.T) {
+			if got := GetAPIPort(); got != tt.want {
+				t.Errorf("GetAPIPort() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+
+}
+
 func TestIsCacheDisabled(t *testing.T) {
 	tests := []struct {
 		name string

+ 3 - 3
pkg/kubecost/asset.go

@@ -4,7 +4,6 @@ import (
 	"encoding"
 	"fmt"
 	"math"
-	"regexp"
 	"strings"
 	"time"
 
@@ -12,6 +11,7 @@ import (
 	"github.com/opencost/opencost/pkg/filter21/ast"
 	"github.com/opencost/opencost/pkg/filter21/matcher"
 	"github.com/opencost/opencost/pkg/log"
+	"github.com/opencost/opencost/pkg/prom"
 	"github.com/opencost/opencost/pkg/util/json"
 	"github.com/opencost/opencost/pkg/util/timeutil"
 )
@@ -4139,8 +4139,8 @@ func GetNodePoolName(provider string, labels map[string]string) string {
 }
 
 func getPoolNameHelper(label string, labels map[string]string) string {
-	sanitizedLabel := regexp.MustCompile(`[^a-zA-Z0-9 ]+`).ReplaceAllString(label, "_")
-	if poolName, found := labels[fmt.Sprintf("label_%s", sanitizedLabel)]; found {
+	sanitizedLabel := prom.SanitizeLabelName(label)
+	if poolName, found := labels[sanitizedLabel]; found {
 		return poolName
 	} else {
 		log.Warnf("unable to derive node pool name from node labels")

+ 10 - 1
ui/Dockerfile

@@ -7,8 +7,17 @@ RUN npx parcel build src/index.html
 
 FROM nginx:alpine
 
+ARG version=dev
+ARG	commit=HEAD
+ENV VERSION=${version}
+ENV HEAD=${commit}
+
+ENV API_PORT=9003
+ENV API_SERVER=0.0.0.0
+ENV UI_PORT=9090
+
 COPY --from=builder /opt/ui/dist /var/www
-COPY default.nginx.conf /etc/nginx/conf.d/
+COPY default.nginx.conf.template /etc/nginx/conf.d/default.nginx.conf.template
 COPY nginx.conf /etc/nginx/
 COPY ./docker-entrypoint.sh /usr/local/bin/
 

+ 10 - 1
ui/Dockerfile.cross

@@ -1,7 +1,16 @@
 FROM nginx:alpine
 
+ARG version=dev
+ARG	commit=HEAD
+ENV VERSION=${version}
+ENV HEAD=${commit}
+
+ENV API_PORT=9003
+ENV API_SERVER=0.0.0.0
+ENV UI_PORT=9090
+
 COPY ./dist /var/www
-COPY default.nginx.conf /etc/nginx/conf.d/
+COPY default.nginx.conf.template /etc/nginx/conf.d/default.nginx.conf.template
 COPY nginx.conf /etc/nginx/
 COPY ./docker-entrypoint.sh /usr/local/bin/
 

+ 3 - 3
ui/default.nginx.conf → ui/default.nginx.conf.template

@@ -35,7 +35,7 @@ gzip_types
 upstream model {
     # Update to the cost model endpoint
     # Example: host.docker.internal:9003;
-    server 0.0.0.0:9003;
+    server ${API_SERVER}:${API_PORT};
 }
 
 server {
@@ -58,8 +58,8 @@ server {
     }
 
     add_header ETag "1.96.0";
-    listen 9090;
-    listen [::]:9090;
+    listen ${UI_PORT};
+    listen [::]:${UI_PORT};
     resolver 127.0.0.1 valid=5s;
     location /healthz {
         access_log /dev/null;

+ 6 - 2
ui/docker-entrypoint.sh

@@ -4,10 +4,14 @@ 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 
+else
     echo "running with BASE_URL=${BASE_URL}"
     sed -i "s^{PLACEHOLDER_BASE_URL}^$BASE_URL^g" /var/www/*.js
 fi
 
+envsubst '$API_PORT $API_SERVER $UI_PORT' < /etc/nginx/conf.d/default.nginx.conf.template > /etc/nginx/conf.d/default.nginx.conf
+
+echo "Starting ui version $VERSION ($HEAD)"
+
 # Run the parent (nginx) container's entrypoint script
-exec /docker-entrypoint.sh "$@"
+exec /docker-entrypoint.sh "$@"

+ 7 - 0
ui/justfile

@@ -1,3 +1,6 @@
+version := `../tools/image-tag`
+commit := `git rev-parse --short HEAD`
+
 default:
     just --list
 
@@ -13,6 +16,8 @@ build IMAGETAG: build-local
         -f 'Dockerfile.cross' \
         --provenance=false \
         -t {{IMAGETAG}}-amd64 \
+        --build-arg version={{version}} \
+        --build-arg commit={{commit}} \
         --push \
         .
 
@@ -22,6 +27,8 @@ build IMAGETAG: build-local
         -f 'Dockerfile.cross' \
         --provenance=false \
         -t {{IMAGETAG}}-arm64 \
+        --build-arg version={{version}} \
+        --build-arg commit={{commit}} \
         --push \
         .
 

Plik diff jest za duży
+ 451 - 519
ui/package-lock.json


+ 1 - 1
ui/package.json

@@ -21,7 +21,7 @@
     "@material-ui/icons": "^4.11.2",
     "@material-ui/pickers": "^3.3.10",
     "@material-ui/styles": "^4.11.5",
-    "axios": "^1.4.0",
+    "axios": "^1.6.0",
     "date-fns": "^2.30.0",
     "material-design-icons-iconfont": "^6.1.0",
     "prop-types": "^15.7.2",

+ 2 - 2
ui/src/cloudCost/cloudCostDetails.js

@@ -33,7 +33,7 @@ const CloudCostDetails = ({
 
   const nextFilters = [
     ...(filters ?? []),
-    { property: "providerIds", value: selectedProviderId },
+    { property: "providerID", value: selectedProviderId },
   ];
 
   async function fetchData() {
@@ -122,7 +122,7 @@ const CloudCostDetails = ({
         title={`Costs over the last ${window}`}
         style={{ margin: "10%" }}
       >
-        <Paper>
+        <Paper style={{ padding: 20 }}>
           <Typography style={{ marginTop: "1rem" }} variant="body1">
             {selectedItem}
           </Typography>

+ 1 - 1
ui/src/cloudCostReports.js

@@ -175,7 +175,6 @@ const CloudCostReports = () => {
       return {
         property,
         value,
-        name: aggMap[property] || property,
       };
     });
     setFilters(newFilters);
@@ -267,6 +266,7 @@ const CloudCostReports = () => {
               aggregationOptions={aggregationOptions}
               aggregateBy={aggregateBy}
               setAggregateBy={(agg) => {
+                setFilters([])
                 searchParams.set("agg", agg);
                 routerHistory.push({
                   search: `?${searchParams.toString()}`,

+ 2 - 3
ui/src/services/cloudCostDayTotals.js

@@ -1,5 +1,5 @@
 import axios from "axios";
-import { getCloudFilters } from "../util";
+import { parseFilters } from "../util";
 import { costMetricToPropName } from "../cloudCost/tokens";
 
 function formatItemsForCost({ data, costType }) {
@@ -21,12 +21,11 @@ class CloudCostDayTotalsService {
     if (this.BASE_URL.includes("PLACEHOLDER_BASE_URL")) {
       this.BASE_URL = `http://localhost:9090/model`;
     }
-
     if (aggregate.includes("item")) {
       const resp = await axios.get(
         `${
           this.BASE_URL
-        }/cloudCost?window=${window}&costMetric=${costMetric}${getCloudFilters(
+        }/cloudCost?window=${window}&costMetric=${costMetric}&filter=${parseFilters(
           filters
         )}`
       );

+ 3 - 3
ui/src/services/cloudCostTop.js

@@ -1,5 +1,5 @@
 import axios from "axios";
-import { getCloudFilters, formatSampleItemsForGraph } from "../util";
+import { formatSampleItemsForGraph, parseFilters } from "../util";
 
 class CloudCostTopService {
   BASE_URL = process.env.BASE_URL || "{PLACEHOLDER_BASE_URL}";
@@ -13,7 +13,7 @@ class CloudCostTopService {
       window,
       aggregate,
       costMetric,
-      filters,
+      filter: parseFilters(filters ?? []),
       limit: 1000,
     };
 
@@ -21,7 +21,7 @@ class CloudCostTopService {
       const resp = await axios.get(
         `${
           this.BASE_URL
-        }/cloudCost?window=${window}&costMetric=${costMetric}${getCloudFilters(
+        }/cloudCost?window=${window}&costMetric=${costMetric}&filter=${parseFilters(
           filters
         )}`
       );

+ 32 - 47
ui/src/util.js

@@ -344,35 +344,6 @@ export function checkCustomWindow(window) {
   return customDateRegex.test(window);
 }
 
-export function getCloudFilters(filters) {
-  const filterNamesMap = {
-    "invoice entity": "filterInvoiceEntityIDs",
-    provider: "filterProviders",
-    providerids: "filterProviderIDs",
-    service: "filterServices",
-    account: "filterAccountIDs",
-  };
-  const params = new URLSearchParams();
-  const labelFilters = [];
-
-  for (let filter of filters) {
-    const mapped = filterNamesMap[filter.property.toLowerCase()];
-
-    if (mapped) {
-      params.set(mapped, filter.value);
-    } else if (filter.property === "Labels") {
-      labelFilters.push(filter.value);
-    } else if (filter.property.startsWith(":")) {
-      labelFilters.push(`${filter.property.slice(6)}:${filter.value}`);
-    }
-  }
-  if (labelFilters.length) {
-    params.set("filterLabels", labelFilters.join(","));
-  }
-
-  return `&${params.toString()}`;
-}
-
 export function formatSampleItemsForGraph({ data, costMetric }) {
   const costMetricPropName = costMetric
     ? costMetricToPropName[costMetric]
@@ -412,29 +383,31 @@ export function formatSampleItemsForGraph({ data, costMetric }) {
         cloudCostItem[costMetricPropName].kubernetesPercent;
     });
   });
-  const tableRows = Object.entries(accumulator).map(
-    ([
-      name,
-      {
+  const tableRows = Object.entries(accumulator)
+    .map(
+      ([
+        name,
+        {
+          cost,
+          start,
+          end,
+          providerID,
+          kubernetesCost,
+          kubernetesPercent,
+          labelName,
+        },
+      ]) => ({
         cost,
+        name,
+        kubernetesCost,
+        kubernetesPercent,
         start,
         end,
         providerID,
-        kubernetesCost,
-        kubernetesPercent,
         labelName,
-      },
-    ]) => ({
-      cost,
-      name,
-      kubernetesCost,
-      kubernetesPercent,
-      start,
-      end,
-      providerID,
-      labelName,
-    })
-  );
+      })
+    )
+    .sort((a, b) => (a.cost > b.cost ? -1 : 1));
 
   const tableTotal = tableRows.reduce(
     (tr1, tr2) => ({
@@ -457,6 +430,18 @@ export function formatSampleItemsForGraph({ data, costMetric }) {
   return { graphData, tableRows, tableTotal };
 }
 
+export function parseFilters(filters) {
+  if (typeof filters === "string") {
+    return filters;
+  }
+  // remove dups (via context ) and format
+  return (
+    [...new Set(filters.map((f) => `${f.property}:"${f.value}"`))].join(
+      encodeURIComponent("+")
+    ) || ""
+  );
+}
+
 export default {
   rangeToCumulative,
   cumulativeToTotals,

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików