ソースを参照

Merge branch 'develop' of https://github.com/kubecost/cost-model into AjayTripathy-use-pod-label

Ajay Tripathy 5 年 前
コミット
4d5c7715e1

+ 1 - 0
.dockerignore

@@ -0,0 +1 @@
+.git

+ 1 - 9
Dockerfile

@@ -11,17 +11,9 @@ RUN go mod download
 COPY . .
 # Build the binary
 RUN set -e ;\
-    GIT_COMMIT=`git rev-parse HEAD` ;\
-    GIT_DIRTY='' ;\
-    # for our purposes, we only care about dirty .go files ;\
-    if test -n "`git status --porcelain --untracked-files=no | grep '\.go'`"; then \
-      GIT_DIRTY='+dirty' ;\
-    fi ;\
     cd cmd/costmodel;\
     CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
-    go build -a -installsuffix cgo \
-        -ldflags "-X main.gitCommit=${GIT_COMMIT}${GIT_DIRTY}" \
-        -o /go/bin/app
+    go build -a -installsuffix cgo -o /go/bin/app
 
 FROM alpine:3.10.2
 RUN apk add --update --no-cache ca-certificates

+ 1 - 0
go.mod

@@ -17,6 +17,7 @@ require (
 	github.com/jszwec/csvutil v1.2.1
 	github.com/julienschmidt/httprouter v1.2.0
 	github.com/lib/pq v1.2.0
+	github.com/microcosm-cc/bluemonday v1.0.2
 	github.com/patrickmn/go-cache v2.1.0+incompatible
 	github.com/prometheus/client_golang v1.0.0
 	github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90

+ 1 - 0
go.sum

@@ -241,6 +241,7 @@ github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0j
 github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
 github.com/mediocregopher/mediocre-go-lib v0.0.0-20181029021733-cb65787f37ed/go.mod h1:dSsfyI2zABAdhcbvkXqgxOxrCsbYeHCPgrZkku60dSg=
 github.com/mediocregopher/radix/v3 v3.3.0/go.mod h1:EmfVyvspXz1uZEyPBMyGK+kjWiKQGvsUt6O3Pj+LDCQ=
+github.com/microcosm-cc/bluemonday v1.0.2 h1:5lPfLTTAvAbtS0VqT+94yOtFnGfUWYyx0+iToC3Os3s=
 github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc=
 github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
 github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=

+ 7 - 0
pkg/cloud/awsprovider.go

@@ -1770,6 +1770,13 @@ func (a *AWS) ExternalAllocations(start string, end string, aggregators []string
 	}
 	s := session.Must(session.NewSession(c))
 	svc := athena.New(s)
+	if customPricing.MasterPayerARN != "" {
+		creds := stscreds.NewCredentials(s, customPricing.MasterPayerARN)
+		svc = athena.New(s, &aws.Config{
+			Region:      region,
+			Credentials: creds,
+		})
+	}
 
 	var e athena.StartQueryExecutionInput
 

+ 48 - 16
pkg/cloud/azureprovider.go

@@ -28,8 +28,11 @@ import (
 )
 
 const (
-	AzurePremiumStorageClass  = "premium"
-	AzureStandardStorageClass = "standard"
+	AzureFilePremiumStorageClass     = "premium_smb"
+	AzureFileStandardStorageClass    = "standard_smb"
+	AzureDiskPremiumSSDStorageClass  = "premium_ssd"
+	AzureDiskStandardSSDStorageClass = "standard_ssd"
+	AzureDiskStandardStorageClass    = "standard_hdd"
 )
 
 var (
@@ -478,12 +481,16 @@ func (az *Azure) DownloadPricingData() error {
 		if !strings.Contains(meterSubCategory, "Windows") {
 
 			if strings.Contains(meterCategory, "Storage") {
-				if strings.Contains(meterSubCategory, "HDD") || strings.Contains(meterSubCategory, "SSD") {
+				if strings.Contains(meterSubCategory, "HDD") || strings.Contains(meterSubCategory, "SSD") || strings.Contains(meterSubCategory, "Premium Files") {
 					var storageClass string = ""
-					if strings.Contains(meterName, "S4 ") {
-						storageClass = AzureStandardStorageClass
-					} else if strings.Contains(meterName, "P4 ") {
-						storageClass = AzurePremiumStorageClass
+					if strings.Contains(meterName, "P4 ") {
+						storageClass = AzureDiskPremiumSSDStorageClass
+					} else if strings.Contains(meterName, "E4 ") {
+						storageClass = AzureDiskStandardSSDStorageClass
+					} else if strings.Contains(meterName, "S4 ") {
+						storageClass = AzureDiskStandardStorageClass
+					} else if strings.Contains(meterName, "LRS Provisioned") {
+						storageClass = AzureFilePremiumStorageClass
 					}
 
 					if storageClass != "" {
@@ -514,11 +521,6 @@ func (az *Azure) DownloadPricingData() error {
 
 			if strings.Contains(meterCategory, "Virtual Machines") {
 
-				// not available now
-				if strings.Contains(meterSubCategory, "Promo") {
-					continue
-				}
-
 				usageType := ""
 				if !strings.Contains(meterName, "Low Priority") {
 					usageType = "ondemand"
@@ -530,6 +532,9 @@ func (az *Azure) DownloadPricingData() error {
 				name := strings.TrimSuffix(meterName, " Low Priority")
 				instanceType := strings.Split(name, "/")
 				for _, it := range instanceType {
+					if strings.Contains(meterSubCategory, "Promo") {
+						it = it + " Promo"
+					}
 					instanceTypes = append(instanceTypes, strings.Replace(it, " ", "_", 1))
 				}
 
@@ -561,6 +566,22 @@ func (az *Azure) DownloadPricingData() error {
 			}
 		}
 	}
+
+	// There is no easy way of supporting Standard Azure-File, because it's billed per used GB
+	// this will set the price to "0" as a workaround to not spam with `Persistent Volume pricing not found for` error
+	// check https://github.com/kubecost/cost-model/issues/159 for more information (same problem on AWS)
+	zeroPrice := "0.0"
+	for region := range regions {
+		key := region + "," + AzureFileStandardStorageClass
+		klog.V(4).Infof("Adding PV.Key: %s, Cost: %s", key, zeroPrice)
+		allPrices[key] = &AzurePricing{
+			PV: &PV{
+				Cost:   zeroPrice,
+				Region: region,
+			},
+		}
+	}
+
 	az.Pricing = allPrices
 	return nil
 }
@@ -651,10 +672,21 @@ func (key *azurePvKey) GetStorageClass() string {
 
 func (key *azurePvKey) Features() string {
 	storageClass := key.StorageClassParameters["storageaccounttype"]
-	if strings.EqualFold(storageClass, "Premium_LRS") {
-		storageClass = AzurePremiumStorageClass
-	} else if strings.EqualFold(storageClass, "Standard_LRS") {
-		storageClass = AzureStandardStorageClass
+	storageSKU := key.StorageClassParameters["skuName"]
+	if storageClass != "" {
+		if strings.EqualFold(storageClass, "Premium_LRS") {
+			storageClass = AzureDiskPremiumSSDStorageClass
+		} else if strings.EqualFold(storageClass, "StandardSSD_LRS") {
+			storageClass = AzureDiskStandardSSDStorageClass
+		} else if strings.EqualFold(storageClass, "Standard_LRS") {
+			storageClass = AzureDiskStandardStorageClass
+		}
+	} else {
+		if strings.EqualFold(storageSKU, "Premium_LRS") {
+			storageClass = AzureFilePremiumStorageClass
+		} else if strings.EqualFold(storageSKU, "Standard_LRS") {
+			storageClass = AzureFileStandardStorageClass
+		}
 	}
 	if region, ok := key.Labels[v1.LabelZoneRegion]; ok {
 		return region + "," + storageClass

+ 10 - 3
pkg/cloud/gcpprovider.go

@@ -166,6 +166,9 @@ func (gcp *GCP) GetConfig() (*CustomPricing, error) {
 	if c.NegotiatedDiscount == "" {
 		c.NegotiatedDiscount = "0%"
 	}
+	if c.CurrencyCode == "" {
+		c.CurrencyCode = "USD"
+	}
 	return c, nil
 }
 
@@ -575,7 +578,7 @@ type GCPPricing struct {
 type PricingInfo struct {
 	Summary                string             `json:"summary"`
 	PricingExpression      *PricingExpression `json:"pricingExpression"`
-	CurrencyConversionRate int                `json:"currencyConversionRate"`
+	CurrencyConversionRate float64            `json:"currencyConversionRate"`
 	EffectiveTime          string             `json:""`
 }
 
@@ -874,7 +877,11 @@ func (gcp *GCP) parsePage(r io.Reader, inputKeys map[string]Key, pvKeys map[stri
 
 func (gcp *GCP) parsePages(inputKeys map[string]Key, pvKeys map[string]PVKey) (map[string]*GCPPricing, error) {
 	var pages []map[string]*GCPPricing
-	url := "https://cloudbilling.googleapis.com/v1/services/6F81-5844-456A/skus?key=" + gcp.APIKey
+	c, err := gcp.GetConfig()
+	if err != nil {
+		return nil, err
+	}
+	url := "https://cloudbilling.googleapis.com/v1/services/6F81-5844-456A/skus?key=" + gcp.APIKey + "&currencyCode=" + c.CurrencyCode
 	klog.V(2).Infof("Fetch GCP Billing Data from URL: %s", url)
 	var parsePagesHelper func(string) error
 	parsePagesHelper = func(pageToken string) error {
@@ -894,7 +901,7 @@ func (gcp *GCP) parsePages(inputKeys map[string]Key, pvKeys map[string]PVKey) (m
 		pages = append(pages, page)
 		return parsePagesHelper(token)
 	}
-	err := parsePagesHelper("")
+	err = parsePagesHelper("")
 	if err != nil {
 		return nil, err
 	}

+ 4 - 1
pkg/cloud/providerconfig.go

@@ -11,10 +11,13 @@ import (
 
 	"github.com/kubecost/cost-model/pkg/env"
 	"github.com/kubecost/cost-model/pkg/util"
+	"github.com/microcosm-cc/bluemonday"
 
 	"k8s.io/klog"
 )
 
+var sanitizePolicy = bluemonday.UGCPolicy()
+
 // ProviderConfig is a utility class that provides a thread-safe configuration
 // storage/cache for all Provider implementations
 type ProviderConfig struct {
@@ -122,7 +125,6 @@ func (pc *ProviderConfig) Update(updateFunc func(*CustomPricing) error) (*Custom
 	if err != nil {
 		return c, err
 	}
-
 	err = ioutil.WriteFile(pc.configPath, cj, 0644)
 
 	if err != nil {
@@ -188,6 +190,7 @@ func SetCustomPricingField(obj *CustomPricing, name string, value string) error
 	}
 
 	structFieldType := structFieldValue.Type()
+	value = sanitizePolicy.Sanitize(value)
 	val := reflect.ValueOf(value)
 	if structFieldType != val.Type() {
 		return fmt.Errorf("Provided value type didn't match custom pricing field type")

+ 15 - 1
pkg/costmodel/cluster.go

@@ -346,6 +346,10 @@ func ClusterNodes(cp cloud.Provider, client prometheus.Client, duration, offset
 		nodeMap[key].CPUCost += cpuCost
 		nodeMap[key].NodeType = nodeType
 	}
+	partialCPUMap := make(map[string]float64)
+	partialCPUMap["e2-micro"] = 0.25
+	partialCPUMap["e2-small"] = 0.5
+	partialCPUMap["e2-medium"] = 1.0
 
 	for _, result := range resNodeCPUCores {
 		cluster, err := result.GetString("cluster_id")
@@ -368,7 +372,17 @@ func ClusterNodes(cp cloud.Provider, client prometheus.Client, duration, offset
 				Name:    name,
 			}
 		}
-		nodeMap[key].CPUCores = cpuCores
+		node := nodeMap[key]
+		if v, ok := partialCPUMap[node.NodeType]; ok {
+			node.CPUCores = v
+			if cpuCores > 0 {
+				adjustmentFactor := v / cpuCores
+				node.CPUCost = node.CPUCost * adjustmentFactor
+			}
+		} else {
+			nodeMap[key].CPUCores = cpuCores
+		}
+
 	}
 
 	for _, result := range resNodeRAMCost {

+ 70 - 0
pkg/costmodel/clusterinfo.go

@@ -0,0 +1,70 @@
+package costmodel
+
+import (
+	"fmt"
+
+	cloudProvider "github.com/kubecost/cost-model/pkg/cloud"
+	"github.com/kubecost/cost-model/pkg/env"
+	"github.com/kubecost/cost-model/pkg/thanos"
+
+	"k8s.io/client-go/kubernetes"
+	"k8s.io/klog"
+)
+
+var (
+	logCollectionEnabled    bool   = env.IsLogCollectionEnabled()
+	productAnalyticsEnabled bool   = env.IsProductAnalyticsEnabled()
+	errorReportingEnabled   bool   = env.IsErrorReportingEnabled()
+	valuesReportingEnabled  bool   = env.IsValuesReportingEnabled()
+	clusterProfile          string = env.GetClusterProfile()
+)
+
+// writeReportingFlags writes the reporting flags to the cluster info map
+func writeReportingFlags(clusterInfo map[string]string) {
+	clusterInfo["logCollection"] = fmt.Sprintf("%t", logCollectionEnabled)
+	clusterInfo["productAnalytics"] = fmt.Sprintf("%t", productAnalyticsEnabled)
+	clusterInfo["errorReporting"] = fmt.Sprintf("%t", errorReportingEnabled)
+	clusterInfo["valuesReporting"] = fmt.Sprintf("%t", valuesReportingEnabled)
+}
+
+// writeClusterProfile writes the data associated with the cluster profile
+func writeClusterProfile(clusterInfo map[string]string) {
+	clusterInfo["clusterProfile"] = clusterProfile
+}
+
+func writeThanosFlags(clusterInfo map[string]string) {
+	// Include Thanos Offset Duration if Applicable
+	clusterInfo["thanosEnabled"] = fmt.Sprintf("%t", thanos.IsEnabled())
+	if thanos.IsEnabled() {
+		clusterInfo["thanosOffset"] = thanos.Offset()
+	}
+}
+
+// GetClusterInfo provides specific information about the cluster cloud provider as well as
+// generic configuration values.
+func GetClusterInfo(kubeClient kubernetes.Interface, cloud cloudProvider.Provider) map[string]string {
+	data, err := cloud.ClusterInfo()
+
+	// Ensure we create the info object if it doesn't exist
+	if data == nil {
+		data = make(map[string]string)
+	}
+
+	kc, ok := kubeClient.(*kubernetes.Clientset)
+	if ok && data != nil {
+		v, err := kc.ServerVersion()
+		if err != nil {
+			klog.Infof("Could not get k8s version info: %s", err.Error())
+		} else if v != nil {
+			data["version"] = v.Major + "." + v.Minor
+		}
+	} else {
+		klog.Infof("Could not get k8s version info: %s", err.Error())
+	}
+
+	writeClusterProfile(data)
+	writeReportingFlags(data)
+	writeThanosFlags(data)
+
+	return data
+}

+ 9 - 7
pkg/costmodel/costmodel.go

@@ -477,6 +477,7 @@ func (cm *CostModel) ComputeCostData(cli prometheusClient.Client, clientset kube
 					name := vol.PersistentVolumeClaim.ClaimName
 					key := ns + "," + name + "," + clusterID
 					if pvClaim, ok := pvClaimMapping[key]; ok {
+						pvClaim.TimesClaimed++
 						podPVs = append(podPVs, pvClaim)
 
 						// Remove entry from potential unmounted pvs
@@ -2174,13 +2175,14 @@ func getStatefulSetsOfPod(pod v1.Pod) []string {
 }
 
 type PersistentVolumeClaimData struct {
-	Class      string                `json:"class"`
-	Claim      string                `json:"claim"`
-	Namespace  string                `json:"namespace"`
-	ClusterID  string                `json:"clusterId"`
-	VolumeName string                `json:"volumeName"`
-	Volume     *costAnalyzerCloud.PV `json:"persistentVolume"`
-	Values     []*util.Vector        `json:"values"`
+	Class        string                `json:"class"`
+	Claim        string                `json:"claim"`
+	Namespace    string                `json:"namespace"`
+	ClusterID    string                `json:"clusterId"`
+	TimesClaimed int                   `json:"timesClaimed"`
+	VolumeName   string                `json:"volumeName"`
+	Volume       *costAnalyzerCloud.PV `json:"persistentVolume"`
+	Values       []*util.Vector        `json:"values"`
 }
 
 func measureTime(start time.Time, threshold time.Duration, name string) {

+ 81 - 1
pkg/costmodel/metrics.go

@@ -12,6 +12,7 @@ import (
 	costAnalyzerCloud "github.com/kubecost/cost-model/pkg/cloud"
 	"github.com/kubecost/cost-model/pkg/errors"
 	"github.com/kubecost/cost-model/pkg/log"
+	"github.com/kubecost/cost-model/pkg/prom"
 	"github.com/prometheus/client_golang/prometheus"
 	dto "github.com/prometheus/client_model/go"
 	v1 "k8s.io/api/core/v1"
@@ -283,6 +284,81 @@ func (s ServiceMetric) Write(m *dto.Metric) error {
 	return nil
 }
 
+//--------------------------------------------------------------------------
+//  ClusterInfoCollector
+//--------------------------------------------------------------------------
+
+// ClusterInfoCollector is a prometheus collector that generates ClusterInfoMetrics
+type ClusterInfoCollector struct {
+	Cloud         costAnalyzerCloud.Provider
+	KubeClientSet kubernetes.Interface
+}
+
+// Describe sends the super-set of all possible descriptors of metrics
+// collected by this Collector.
+func (cic ClusterInfoCollector) Describe(ch chan<- *prometheus.Desc) {
+	ch <- prometheus.NewDesc("kubecost_cluster_info", "Kubecost Cluster Info", []string{}, nil)
+}
+
+// Collect is called by the Prometheus registry when collecting metrics.
+func (cic ClusterInfoCollector) Collect(ch chan<- prometheus.Metric) {
+	clusterInfo := GetClusterInfo(cic.KubeClientSet, cic.Cloud)
+	labels := prom.MapToLabels(clusterInfo)
+
+	m := newClusterInfoMetric("kubecost_cluster_info", labels)
+	ch <- m
+}
+
+//--------------------------------------------------------------------------
+//  ClusterInfoMetric
+//--------------------------------------------------------------------------
+
+// ClusterInfoMetric is a prometheus.Metric used to encode the local cluster info
+type ClusterInfoMetric struct {
+	fqName string
+	help   string
+	labels map[string]string
+}
+
+// Creates a new ClusterInfoMetric, implementation of prometheus.Metric
+func newClusterInfoMetric(fqName string, labels map[string]string) ClusterInfoMetric {
+	return ClusterInfoMetric{
+		fqName: fqName,
+		labels: labels,
+		help:   "kubecost_cluster_info ClusterInfo",
+	}
+}
+
+// Desc returns the descriptor for the Metric. This method idempotently
+// returns the same descriptor throughout the lifetime of the Metric.
+func (cim ClusterInfoMetric) Desc() *prometheus.Desc {
+	l := prometheus.Labels{}
+	return prometheus.NewDesc(cim.fqName, cim.help, prom.LabelNamesFrom(cim.labels), l)
+}
+
+// Write encodes the Metric into a "Metric" Protocol Buffer data
+// transmission object.
+func (cim ClusterInfoMetric) Write(m *dto.Metric) error {
+	h := float64(1)
+	m.Gauge = &dto.Gauge{
+		Value: &h,
+	}
+	var labels []*dto.LabelPair
+	for k, v := range cim.labels {
+		labels = append(labels, &dto.LabelPair{
+			Name:  toStringPtr(k),
+			Value: toStringPtr(v),
+		})
+	}
+	m.Label = labels
+	return nil
+}
+
+// toStringPtr is used to create a new string pointer from iteration vars
+func toStringPtr(s string) *string {
+	return &s
+}
+
 //--------------------------------------------------------------------------
 //  Package Functions
 //--------------------------------------------------------------------------
@@ -445,7 +521,11 @@ func StartCostModelMetricRecording(a *Accesses) bool {
 				if costs.PVCData != nil {
 					for _, pvc := range costs.PVCData {
 						if pvc.Volume != nil {
-							a.PVAllocationRecorder.WithLabelValues(namespace, podName, pvc.Claim, pvc.VolumeName).Set(pvc.Values[0].Value)
+							timesClaimed := pvc.TimesClaimed
+							if timesClaimed == 0 {
+								timesClaimed = 1 // unallocated PVs are unclaimed but have a full allocation
+							}
+							a.PVAllocationRecorder.WithLabelValues(namespace, podName, pvc.Claim, pvc.VolumeName).Set(pvc.Values[0].Value / float64(pvc.TimesClaimed))
 							labelKey := getKeyFromLabelStrings(namespace, podName, pvc.Claim, pvc.VolumeName)
 							pvcSeen[labelKey] = true
 						}

+ 22 - 44
pkg/costmodel/router.go

@@ -45,11 +45,6 @@ const (
 var (
 	// gitCommit is set by the build system
 	gitCommit                       string
-	logCollectionEnabled            bool   = env.IsLogCollectionEnabled()
-	productAnalyticsEnabled         bool   = env.IsProductAnalyticsEnabled()
-	errorReportingEnabled           bool   = env.IsErrorReportingEnabled()
-	valuesReportingEnabled          bool   = env.IsValuesReportingEnabled()
-	clusterProfile                  string = env.GetClusterProfile()
 	multiclusterDBBasicAuthUsername string = env.GetMultiClusterBasicAuthUsername()
 	multiclusterDBBasicAuthPW       string = env.GetMultiClusterBasicAuthPassword()
 )
@@ -169,14 +164,6 @@ func normalizeTimeParam(param string) (string, error) {
 	return param, nil
 }
 
-// writeReportingFlags writes the reporting flags to the cluster info map
-func writeReportingFlags(clusterInfo map[string]string) {
-	clusterInfo["logCollection"] = fmt.Sprintf("%t", logCollectionEnabled)
-	clusterInfo["productAnalytics"] = fmt.Sprintf("%t", productAnalyticsEnabled)
-	clusterInfo["errorReporting"] = fmt.Sprintf("%t", errorReportingEnabled)
-	clusterInfo["valuesReporting"] = fmt.Sprintf("%t", valuesReportingEnabled)
-}
-
 // parsePercentString takes a string of expected format "N%" and returns a floating point 0.0N.
 // If the "%" symbol is missing, it just returns 0.0N. Empty string is interpreted as "0%" and
 // return 0.0.
@@ -343,7 +330,22 @@ func (a *Accesses) ClusterCosts(w http.ResponseWriter, r *http.Request, ps httpr
 	window := r.URL.Query().Get("window")
 	offset := r.URL.Query().Get("offset")
 
-	data, err := ComputeClusterCosts(a.PrometheusClient, a.Cloud, window, offset, true)
+	useThanos, _ := strconv.ParseBool(r.URL.Query().Get("multi"))
+
+	if useThanos && !thanos.IsEnabled() {
+		w.Write(WrapData(nil, fmt.Errorf("Multi=true while Thanos is not enabled.")))
+		return
+	}
+
+	var client prometheusClient.Client
+	if useThanos {
+		client = a.ThanosClient
+		offset = thanos.Offset()
+	} else {
+		client = a.PrometheusClient
+	}
+
+	data, err := ComputeClusterCosts(client, a.Cloud, window, offset, true)
 	w.Write(WrapData(data, err))
 }
 
@@ -613,37 +615,9 @@ func (p *Accesses) ClusterInfo(w http.ResponseWriter, r *http.Request, ps httpro
 	w.Header().Set("Content-Type", "application/json")
 	w.Header().Set("Access-Control-Allow-Origin", "*")
 
-	data, err := p.Cloud.ClusterInfo()
-
-	kc, ok := p.KubeClientSet.(*kubernetes.Clientset)
-	if ok && data != nil {
-		v, err := kc.ServerVersion()
-		if err != nil {
-			klog.Infof("Could not get k8s version info: %s", err.Error())
-		} else if v != nil {
-			data["version"] = v.Major + "." + v.Minor
-		}
-	} else {
-		klog.Infof("Could not get k8s version info: %s", err.Error())
-	}
-
-	// Ensure we create the info object if it doesn't exist
-	if data == nil {
-		data = make(map[string]string)
-	}
-
-	data["clusterProfile"] = clusterProfile
-
-	// Include Product Reporting Flags with Cluster Info
-	writeReportingFlags(data)
+	data := GetClusterInfo(p.KubeClientSet, p.Cloud)
 
-	// Include Thanos Offset Duration if Applicable
-	data["thanosEnabled"] = fmt.Sprintf("%t", thanos.IsEnabled())
-	if thanos.IsEnabled() {
-		data["thanosOffset"] = thanos.Offset()
-	}
-
-	w.Write(WrapData(data, err))
+	w.Write(WrapData(data, nil))
 }
 
 func (p *Accesses) GetServiceAccountStatus(w http.ResponseWriter, _ *http.Request, _ httprouter.Params) {
@@ -932,6 +906,10 @@ func Initialize(additionalConfigWatchers ...ConfigWatchers) {
 	prometheus.MustRegister(StatefulsetCollector{
 		KubeClientSet: kubeClientset,
 	})
+	prometheus.MustRegister(ClusterInfoCollector{
+		KubeClientSet: kubeClientset,
+		Cloud:         cloudProvider,
+	})
 
 	// cache responses from model for a default of 5 minutes; clear expired responses every 10 minutes
 	outOfClusterCache := cache.New(time.Minute*5, time.Minute*10)

+ 69 - 0
pkg/prom/metrics.go

@@ -0,0 +1,69 @@
+package prom
+
+import (
+	"encoding/json"
+	"fmt"
+	"reflect"
+	"strings"
+)
+
+// AnyToLabels will create prometheus labels based on the fields of the interface
+// passed. Note that this method is quite expensive and should only be used when absolutely
+// necessary.
+func AnyToLabels(a interface{}) (map[string]string, error) {
+	val := reflect.ValueOf(a)
+	if val.Kind() == reflect.Map {
+		return MapToLabels(a), nil
+	}
+
+	b, e := json.Marshal(a)
+	if e != nil {
+		return nil, e
+	}
+
+	var m map[string]interface{}
+	e = json.Unmarshal(b, &m)
+	if e != nil {
+		return nil, e
+	}
+
+	return MapToLabels(m), nil
+}
+
+// MapToLabels accepts a map type, and will return a new map containing all the nested
+// fields separated by _ with string versions of the values.
+func MapToLabels(m interface{}) map[string]string {
+	val := reflect.ValueOf(m)
+	if val.Kind() != reflect.Map {
+		return map[string]string{}
+	}
+
+	r := make(map[string]string)
+
+	for _, k := range val.MapKeys() {
+		key := strings.ToLower(k.String())
+		v := val.MapIndex(k).Interface()
+
+		switch v.(type) {
+		case uint, uint8, uint16, uint32, uint64, int, int8, int16, int32, int64, string, bool, float32, float64:
+			r[key] = fmt.Sprintf("%+v", v)
+
+		default:
+			mm := MapToLabels(v)
+			for kk, vv := range mm {
+				r[fmt.Sprintf("%s_%s", key, kk)] = vv
+			}
+		}
+	}
+
+	return r
+}
+
+// LabelNamesFrom accepts a mapping of labels to values and returns the label names.
+func LabelNamesFrom(labels map[string]string) []string {
+	keys := []string{}
+	for key := range labels {
+		keys = append(keys, key)
+	}
+	return keys
+}

+ 28 - 0
test/clusterinfo_test.go

@@ -0,0 +1,28 @@
+package test
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/kubecost/cost-model/pkg/prom"
+)
+
+func TestClusterInfoLabels(t *testing.T) {
+	expected := map[string]bool{"clusterprofile": true, "errorreporting": true, "id": true, "logcollection": true, "name": true, "productanalytics": true, "provider": true, "provisioner": true, "remotereadenabled": true, "thanosenabled": true, "valuesreporting": true, "version": true}
+	clusterInfo := `{"clusterProfile":"production","errorReporting":"true","id":"cluster-one","logCollection":"true","name":"bolt-3","productAnalytics":"true","provider":"GCP","provisioner":"GKE","remoteReadEnabled":"false","thanosEnabled":"false","valuesReporting":"true","version":"1.14+"}`
+
+	var m map[string]interface{}
+	err := json.Unmarshal([]byte(clusterInfo), &m)
+	if err != nil {
+		t.Errorf("Error: %s", err)
+		return
+	}
+
+	labels := prom.MapToLabels(m)
+	for k := range expected {
+		if _, ok := labels[k]; !ok {
+			t.Errorf("Failed to locate key: \"%s\" in labels.", k)
+			return
+		}
+	}
+}