2
0
Эх сурвалжийг харах

Merge commit 'cf0db80d81064b9e147dd66be6742b555e20be93' into sth/config-controller-persistence

Sean Holcomb 2 жил өмнө
parent
commit
470395b0dd

+ 105 - 0
.github/workflows/build-and-publish-release.yml

@@ -0,0 +1,105 @@
+name: Build and Publish Release
+
+on:
+  workflow_dispatch:
+    inputs:
+      release_version:
+        description: "Version. Please DO NOT include the 'v' prefix"
+        required: true
+
+concurrency:
+  group: build-opencost
+  cancel-in-progress: true
+
+jobs:
+  build-and-publish-opencost:
+    runs-on: ubuntu-latest
+    permissions:
+      contents: read
+      id-token: write
+    
+    steps:
+      - name: Show Input Values
+        run: |
+          echo "release version: ${{ inputs.release_version }}"
+
+      - name: Make Branch Name
+        id: branch
+        run: |
+          echo "BRANCH_NAME=v${${{ inputs.release_version}}%.*}" >> $GITHUB_ENV
+  
+      - name: Checkout Repo
+        uses: actions/checkout@v4
+        with:
+          repository: 'opencost/opencost'
+          ref: '${{ steps.branch.outputs.BRANCH_NAME }}'
+          path: ./opencost  
+
+      - name: Set SHA
+        id: sha
+        run: |
+          pushd ./opencost
+          echo "OC_SHORTHASH=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
+          popd
+
+      - name: Set OpenCost Image Tags
+        id: tags
+        run: |
+          echo "IMAGE_TAG=ghcr.io/opencost/opencost:${{ steps.sha.outputs.OC_SHORTHASH }}" >> $GITHUB_OUTPUT
+          echo "IMAGE_TAG_LATEST=ghcr.io/opencost/opencost:latest" >> $GITHUB_OUTPUT
+          echo "IMAGE_TAG_VERSION=ghcr.io/opencost/opencost:${{ inputs.release_version }}" >> $GITHUB_OUTPUT
+          echo "IMAGE_TAG_UI=ghcr.io/opencost/opencost-ui:${{ steps.sha.outputs.OC_SHORTHASH }}" >> $GITHUB_OUTPUT
+          echo "IMAGE_TAG_UI_LATEST=ghcr.io/opencost/opencost-ui:latest" >> $GITHUB_OUTPUT
+          echo "IMAGE_TAG_UI_VERSION=ghcr.io/opencost/opencost-ui:${{ inputs.release_version }}" >> $GITHUB_OUTPUT
+          echo "IMAGE_TAG_QUAY=quay.io/kubecost1/kubecost-cost-model:${{ steps.sha.outputs.OC_SHORTHASH }}" >> $GITHUB_OUTPUT
+          echo "IMAGE_TAG_LATEST_QUAY=quay.io/kubecost1/kubecost-cost-model:latest" >> $GITHUB_OUTPUT
+          echo "IMAGE_TAG_VERSION_QUAY=quay.io/kubecost1/kubecost-cost-model:prod-${{ inputs.release_version }}" >> $GITHUB_OUTPUT
+          echo "IMAGE_TAG_UI_QUAY=quay.io/kubecost1/opencost-ui:${{ steps.sha.outputs.OC_SHORTHASH }}" >> $GITHUB_OUTPUT
+          echo "IMAGE_TAG_UI_LATEST_QUAY=quay.io/kubecost1/opencost-ui:latest" >> $GITHUB_OUTPUT
+          echo "IMAGE_TAG_UI_VERSION_QUAY=quay.io/kubecost1/opencost-ui:prod-${{ inputs.release_version }}" >> $GITHUB_OUTPUT
+     
+      - name: Set up Docker Buildx
+        uses: docker/setup-buildx-action@v3
+      
+      - name: Set up just
+        uses: extractions/setup-just@v1
+      
+      - name: Install crane
+        uses: imjasonh/setup-crane@v0.1
+
+      ## Install manifest-tool, which is required to combine multi-arch images
+      ## https://github.com/estesp/manifest-tool
+      - name: Install manifest-tool
+        run: |
+          mkdir -p manifest-tool
+          pushd manifest-tool
+          wget -q https://github.com/estesp/manifest-tool/releases/download/v2.0.8/binaries-manifest-tool-2.0.8.tar.gz
+          tar -xzf binaries-manifest-tool-2.0.8.tar.gz
+          cp manifest-tool-linux-amd64 manifest-tool
+          echo "$(pwd)" >> $GITHUB_PATH
+
+      - name: Login to Quay
+        uses: docker/login-action@v3
+        with:
+          registry: quay.io
+          username: ${{ secrets.QUAY_USERNAME }}
+          password: ${{ secrets.QUAY_PASSWORD }}
+      
+      - name: Build and push (multiarch) OpenCost
+        working-directory: ./opencost
+        run: |
+          just build '${steps.tags.outputs.IMAGE_TAG}'
+          crane copy '${steps.tags.outputs.IMAGE_TAG}' '${steps.tags.outputs.IMAGE_TAG_LATEST}'
+          crane copy '${steps.tags.outputs.IMAGE_TAG}' '${steps.tags.outputs.IMAGE_TAG_VERSION}'
+          crane copy '${steps.tags.outputs.IMAGE_TAG}' '${steps.tags.outputs.IMAGE_TAG_QUAY}'
+          crane copy '${steps.tags.outputs.IMAGE_TAG}' '${steps.tags.outputs.IMAGE_TAG_LATEST_QUAY}'
+          crane copy '${steps.tags.outputs.IMAGE_TAG}' '${steps.tags.outputs.IMAGE_TAG_VERSION_QUAY}'
+
+      - name: Build and push (multiarch) OpenCost UI
+        working-directory: ./opencost/ui
+        run: |
+          just build '${steps.tags.outputs.IMAGE_TAG_UI}' '${steps.tags.outputs.IMAGE_TAG_UI_LATEST}'
+          crane copy '${steps.tags.outputs.IMAGE_TAG_UI}' '${steps.tags.outputs.IMAGE_TAG_UI_VERSION}'
+          crane copy '${steps.tags.outputs.IMAGE_TAG_UI}' '${steps.tags.outputs.IMAGE_TAG_UI_QUAY}'
+          crane copy '${steps.tags.outputs.IMAGE_TAG_UI}' '${steps.tags.outputs.IMAGE_TAG_UI_LATEST_QUAY}'
+          crane copy '${steps.tags.outputs.IMAGE_TAG_UI}' '${steps.tags.outputs.IMAGE_TAG_UI_VERSION_QUAY}'

+ 1 - 1
Dockerfile

@@ -39,7 +39,7 @@ LABEL org.opencontainers.image.documentation=https://opencost.io/docs/
 LABEL org.opencontainers.image.licenses=Apache-2.0
 LABEL org.opencontainers.image.source=https://github.com/opencost/opencost
 LABEL org.opencontainers.image.title=kubecost-cost-model
-LABEL org.opencontainers.image.url=https://github.com/opencost/opencost
+LABEL org.opencontainers.image.url=https://opencost.io
 
 RUN apk add --update --no-cache ca-certificates
 COPY --from=build-env /go/bin/app /go/bin/app

+ 1 - 1
Dockerfile.cross

@@ -5,7 +5,7 @@ LABEL org.opencontainers.image.documentation=https://opencost.io/docs/
 LABEL org.opencontainers.image.licenses=Apache-2.0
 LABEL org.opencontainers.image.source=https://github.com/opencost/opencost
 LABEL org.opencontainers.image.title=kubecost-cost-model
-LABEL org.opencontainers.image.url=https://github.com/opencost/opencost
+LABEL org.opencontainers.image.url=https://opencost.io
 
 # The prebuilt binary path. This Dockerfile assumes the binary will be built
 # outside of Docker.

+ 2 - 2
core/pkg/filter/allocation/parser.go

@@ -8,8 +8,8 @@ var allocationFilterFields []*ast.Field = []*ast.Field{
 	ast.NewField(FieldClusterID),
 	ast.NewField(FieldNode),
 	ast.NewField(FieldNamespace),
-	ast.NewField(FieldControllerName),
-	ast.NewField(FieldControllerKind),
+	ast.NewField(FieldControllerName, ast.FieldAttributeNilable),
+	ast.NewField(FieldControllerKind, ast.FieldAttributeNilable),
 	ast.NewField(FieldContainer),
 	ast.NewField(FieldPod),
 	ast.NewField(FieldProvider),

+ 7 - 5
core/pkg/opencost/allocation.go

@@ -122,11 +122,12 @@ func (orig LbAllocations) Clone() LbAllocations {
 }
 
 type LbAllocation struct {
-	Service string  `json:"service"`
-	Cost    float64 `json:"cost"`
-	Private bool    `json:"private"`
-	Ip      string  `json:"ip"`    //@bingen:field[version=19]
-	Hours   float64 `json:"hours"` //@bingen:field[version=21]
+	Service    string  `json:"service"`
+	Cost       float64 `json:"cost"`
+	Private    bool    `json:"private"`
+	Ip         string  `json:"ip"`         //@bingen:field[version=19]
+	Hours      float64 `json:"hours"`      //@bingen:field[version=21]
+	Adjustment float64 `json:"adjustment"` //@bingen:field[ignore]
 }
 
 func (lba *LbAllocation) SanitizeNaN() {
@@ -312,6 +313,7 @@ type PVAllocation struct {
 	ByteHours  float64 `json:"byteHours"`
 	Cost       float64 `json:"cost"`
 	ProviderID string  `json:"providerID"` // @bingen:field[version=20]
+	Adjustment float64 `json:"adjustment"` //@bingen:field[ignore]
 }
 
 // Equal returns true if the two PVAllocation instances contain approximately the same

+ 2 - 2
kubernetes/exporter/opencost-exporter.yaml

@@ -150,7 +150,7 @@ spec:
               memory: "1G"
           env:
             - name: PROMETHEUS_SERVER_ENDPOINT
-              value: "http://my-prometheus-server.prometheus.svc" # The endpoint should have the form http://<service-name>.<namespace-name>.svc
+              value: "http://prometheus-server.prometheus-system.svc" # The endpoint should have the form http://<service-name>.<namespace-name>.svc
             - name: CLOUD_PROVIDER_API_KEY
               value: "AIzaSyD29bGxmHAVEOBYtgd8sYM2gM2ekfxQX4U" # The GCP Pricing API requires a key. This is supplied just for evaluation.
             - name: CLUSTER_ID
@@ -189,4 +189,4 @@ spec:
     - name: opencost
       port: 9003
       targetPort: 9003
----
+---

+ 1 - 1
kubernetes/opencost.yaml

@@ -153,7 +153,7 @@ spec:
               memory: "1G"
           env:
             - name: PROMETHEUS_SERVER_ENDPOINT
-              value: "http://my-prometheus-server.prometheus.svc" # The endpoint should have the form http://<service-name>.<namespace-name>.svc
+              value: "http://prometheus-server.prometheus-system.svc" # The endpoint should have the form http://<service-name>.<namespace-name>.svc
             - name: CLOUD_PROVIDER_API_KEY
               value: "AIzaSyD29bGxmHAVEOBYtgd8sYM2gM2ekfxQX4U" # The GCP Pricing API requires a key. This is supplied just for evaluation.
             - name: CLUSTER_ID

+ 34 - 20
pkg/cloud/azure/storagebillingparser.go

@@ -1,6 +1,7 @@
 package azure
 
 import (
+	"bytes"
 	"context"
 	"encoding/csv"
 	"fmt"
@@ -59,29 +60,42 @@ func (asbp *AzureStorageBillingParser) ParseBillingData(start, end time.Time, re
 	}
 
 	for _, blobName := range blobNames {
-		localPath := filepath.Join(env.GetConfigPathWithDefault(env.DefaultConfigMountPath), "db", "cloudcost")
-		localFilePath := filepath.Join(localPath, filepath.Base(blobName))
+		if env.IsAzureDownloadBillingDataToDisk() {
+			localPath := filepath.Join(env.GetConfigPathWithDefault(env.DefaultConfigMountPath), "db", "cloudcost")
+			localFilePath := filepath.Join(localPath, filepath.Base(blobName))
 
-		if _, err := asbp.deleteFilesOlderThan7d(localPath); err != nil {
-			log.Warnf("CloudCost: Azure: ParseBillingData: failed to remove the following stale files: %v", err)
-		}
+			if _, err := asbp.deleteFilesOlderThan7d(localPath); err != nil {
+				log.Warnf("CloudCost: Azure: ParseBillingData: failed to remove the following stale files: %v", err)
+			}
 
-		err := asbp.DownloadBlobToFile(localFilePath, blobName, client, ctx)
-		if err != nil {
-			asbp.ConnectionStatus = cloud.FailedConnection
-			return err
-		}
+			err := asbp.DownloadBlobToFile(localFilePath, blobName, client, ctx)
+			if err != nil {
+				asbp.ConnectionStatus = cloud.FailedConnection
+				return err
+			}
 
-		fp, err := os.Open(localFilePath)
-		if err != nil {
-			asbp.ConnectionStatus = cloud.FailedConnection
-			return err
-		}
-		defer fp.Close()
-		err = asbp.parseCSV(start, end, csv.NewReader(fp), resultFn)
-		if err != nil {
-			asbp.ConnectionStatus = cloud.ParseError
-			return err
+			fp, err := os.Open(localFilePath)
+			if err != nil {
+				asbp.ConnectionStatus = cloud.FailedConnection
+				return err
+			}
+			defer fp.Close()
+			err = asbp.parseCSV(start, end, csv.NewReader(fp), resultFn)
+			if err != nil {
+				asbp.ConnectionStatus = cloud.ParseError
+				return err
+			}
+		} else {
+			blobBytes, err2 := asbp.DownloadBlob(blobName, client, ctx)
+			if err2 != nil {
+				asbp.ConnectionStatus = cloud.FailedConnection
+				return err2
+			}
+			err2 = asbp.parseCSV(start, end, csv.NewReader(bytes.NewReader(blobBytes)), resultFn)
+			if err2 != nil {
+				asbp.ConnectionStatus = cloud.ParseError
+				return err2
+			}
 		}
 	}
 	asbp.ConnectionStatus = cloud.SuccessfulConnection

+ 24 - 0
pkg/cloud/azure/storageconnection.go

@@ -1,6 +1,7 @@
 package azure
 
 import (
+	"bytes"
 	"context"
 	"fmt"
 	"os"
@@ -46,6 +47,29 @@ func (sc *StorageConnection) getBlobURLTemplate() string {
 	return "https://%s.blob.core.windows.net/%s"
 }
 
+// DownloadBlob downloads the Azure Billing CSV into a byte slice
+func (sc *StorageConnection) DownloadBlob(blobName string, client *azblob.Client, ctx context.Context) ([]byte, error) {
+	log.Infof("Azure Storage: retrieving blob: %v", blobName)
+
+	downloadResponse, err := client.DownloadStream(ctx, sc.Container, blobName, nil)
+	if err != nil {
+		return nil, fmt.Errorf("Azure: DownloadBlob: failed to download %w", err)
+	}
+	// NOTE: automatically retries are performed if the connection fails
+	retryReader := downloadResponse.NewRetryReader(ctx, &azblob.RetryReaderOptions{})
+	defer retryReader.Close()
+
+	// read the body into a buffer
+	downloadedData := bytes.Buffer{}
+
+	_, err = downloadedData.ReadFrom(retryReader)
+	if err != nil {
+		return nil, fmt.Errorf("Azure: DownloadBlob: failed to read downloaded data %w", err)
+	}
+
+	return downloadedData.Bytes(), nil
+}
+
 // DownloadBlobToFile downloads the Azure Billing CSV to a local file
 func (sc *StorageConnection) DownloadBlobToFile(localFilePath string, blobName string, client *azblob.Client, ctx context.Context) error {
 	// If file exists, don't download it again

+ 1 - 1
pkg/cloud/config/controller.go

@@ -31,7 +31,7 @@ type Controller struct {
 // NewController initializes an Config Controller
 func NewController(cp models.Provider) *Controller {
 	var watchers map[ConfigSource]cloud.KeyedConfigWatcher
-	if env.IsKubernetesEnabled() {
+	if env.IsKubernetesEnabled() && cp != nil {
 		providerConfig := provider.ExtractConfigFromProviders(cp)
 		watchers = GetCloudBillingWatchers(providerConfig)
 	} else {

+ 1 - 1
pkg/cloud/provider/csvprovider.go

@@ -279,7 +279,7 @@ func (c *CSVProvider) NodePricing(key models.Key) (*models.Node, models.PricingM
 			}
 		}
 		totalCost := hourly * float64(count)
-		node.GPUCost = fmt.Sprintf("%f", totalCost)
+		node.GPUCost = fmt.Sprintf("%f", hourly)
 		nc, err := strconv.ParseFloat(node.Cost, 64)
 		if err != nil {
 			log.Errorf("Unable to parse %s as float", node.Cost)

+ 0 - 6
pkg/costmodel/allocation_helpers.go

@@ -1494,13 +1494,11 @@ func applyNodeCostPerCPUHr(nodeMap map[nodeKey]*nodePricing, resNodeCostPerCPUHr
 		instanceType, err := res.GetString("instance_type")
 		if err != nil {
 			log.Warnf("CostModel.ComputeAllocation: Node CPU cost query result missing field: \"%s\" for node \"%s\"", err, node)
-			continue
 		}
 
 		providerID, err := res.GetString("provider_id")
 		if err != nil {
 			log.Warnf("CostModel.ComputeAllocation: Node CPU cost query result missing field: \"%s\" for node \"%s\"", err, node)
-			continue
 		}
 
 		key := newNodeKey(cluster, node)
@@ -1532,13 +1530,11 @@ func applyNodeCostPerRAMGiBHr(nodeMap map[nodeKey]*nodePricing, resNodeCostPerRA
 		instanceType, err := res.GetString("instance_type")
 		if err != nil {
 			log.Warnf("CostModel.ComputeAllocation: Node RAM cost query result missing field: \"%s\" for node \"%s\"", err, node)
-			continue
 		}
 
 		providerID, err := res.GetString("provider_id")
 		if err != nil {
 			log.Warnf("CostModel.ComputeAllocation: Node RAM cost query result missing field: \"%s\" for node \"%s\"", err, node)
-			continue
 		}
 
 		key := newNodeKey(cluster, node)
@@ -1570,13 +1566,11 @@ func applyNodeCostPerGPUHr(nodeMap map[nodeKey]*nodePricing, resNodeCostPerGPUHr
 		instanceType, err := res.GetString("instance_type")
 		if err != nil {
 			log.Warnf("CostModel.ComputeAllocation: Node GPU cost query result missing field: \"%s\" for node \"%s\"", err, node)
-			continue
 		}
 
 		providerID, err := res.GetString("provider_id")
 		if err != nil {
 			log.Warnf("CostModel.ComputeAllocation: Node GPU cost query result missing field: \"%s\" for node \"%s\"", err, node)
-			continue
 		}
 
 		key := newNodeKey(cluster, node)

+ 6 - 1
pkg/costmodel/cluster_helpers.go

@@ -640,7 +640,12 @@ func buildLabelsMap(
 		// 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()
+		if _, ok := m[key]; !ok {
+			m[key] = map[string]string{}
+		}
+		for k, l := range result.GetLabels() {
+			m[key][k] = l
+		}
 	}
 	return m
 }

+ 0 - 1
pkg/costmodel/csv_export.go

@@ -309,7 +309,6 @@ func (e *csvExporter) writeCSVToWriter(ctx context.Context, w io.Writer, dates [
 			},
 		})
 	}
-	csvDef = append(csvDef)
 
 	header := make([]string, 0, len(csvDef))
 	for _, def := range csvDef {

+ 10 - 2
pkg/env/costmodelenv.go

@@ -20,8 +20,9 @@ const (
 	AlibabaAccessKeyIDEnvVar     = "ALIBABA_ACCESS_KEY_ID"
 	AlibabaAccessKeySecretEnvVar = "ALIBABA_SECRET_ACCESS_KEY"
 
-	AzureOfferIDEnvVar        = "AZURE_OFFER_ID"
-	AzureBillingAccountEnvVar = "AZURE_BILLING_ACCOUNT"
+	AzureOfferIDEnvVar                   = "AZURE_OFFER_ID"
+	AzureBillingAccountEnvVar            = "AZURE_BILLING_ACCOUNT"
+	AzureDownloadBillingDataToDiskEnvVar = "AZURE_DOWNLOAD_BILLING_DATA_TO_DISK"
 
 	KubecostNamespaceEnvVar        = "KUBECOST_NAMESPACE"
 	PodNameEnvVar                  = "POD_NAME"
@@ -313,6 +314,13 @@ func GetAzureBillingAccount() string {
 	return env.Get(AzureBillingAccountEnvVar, "")
 }
 
+// IsAzureDownloadBillingDataToDisk returns the environment variable value for
+// AzureDownloadBillingDataToDiskEnvVar which indicates whether the Azure
+// Billing Data should be held in memory or written to disk.
+func IsAzureDownloadBillingDataToDisk() bool {
+	return env.GetBool(AzureDownloadBillingDataToDiskEnvVar, true)
+}
+
 // GetKubecostNamespace returns the environment variable value for KubecostNamespaceEnvVar which
 // represents the namespace the cost model exists in.
 func GetKubecostNamespace() string {

+ 5 - 1
ui/Dockerfile

@@ -12,7 +12,7 @@ LABEL org.opencontainers.image.documentation=https://opencost.io/docs/
 LABEL org.opencontainers.image.licenses=Apache-2.0
 LABEL org.opencontainers.image.source=https://github.com/opencost/opencost
 LABEL org.opencontainers.image.title=opencost-ui
-LABEL org.opencontainers.image.url=https://github.com/opencost/opencost
+LABEL org.opencontainers.image.url=https://opencost.io
 
 ARG version=dev
 ARG	commit=HEAD
@@ -23,8 +23,12 @@ ENV API_PORT=9003
 ENV API_SERVER=0.0.0.0
 ENV UI_PORT=9090
 
+COPY --from=builder /opt/ui/dist /opt/ui/dist
+RUN mkdir -p /var/www
+
 COPY THIRD_PARTY_LICENSES.txt /THIRD_PARTY_LICENSES.txt
 COPY --from=builder /opt/ui/dist /var/www
+
 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 - 2
ui/Dockerfile.cross

@@ -5,7 +5,7 @@ LABEL org.opencontainers.image.documentation=https://opencost.io/docs/
 LABEL org.opencontainers.image.licenses=Apache-2.0
 LABEL org.opencontainers.image.source=https://github.com/opencost/opencost
 LABEL org.opencontainers.image.title=opencost-ui
-LABEL org.opencontainers.image.url=https://github.com/opencost/opencost
+LABEL org.opencontainers.image.url=https://opencost.io
 
 ARG version=dev
 ARG	commit=HEAD
@@ -16,11 +16,12 @@ ENV API_PORT=9003
 ENV API_SERVER=0.0.0.0
 ENV UI_PORT=9090
 
+COPY ./dist /opt/ui/dist
 COPY THIRD_PARTY_LICENSES.txt /THIRD_PARTY_LICENSES.txt
-COPY ./dist /var/www
 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/
+RUN mkdir -p /var/www
 
 RUN rm -rf /etc/nginx/conf.d/default.conf
 

+ 2 - 2
ui/README.md

@@ -5,8 +5,8 @@
 See https://www.opencost.io/docs/install for the full instructions.
 
 ```
-helm install my-prometheus --repo https://prometheus-community.github.io/helm-charts prometheus \
-  --namespace prometheus --create-namespace \
+helm install prometheus --repo https://prometheus-community.github.io/helm-charts prometheus \
+  --namespace prometheus-system --create-namespace \
   --set pushgateway.enabled=false \
   --set alertmanager.enabled=false \
   -f https://raw.githubusercontent.com/opencost/opencost/develop/kubernetes/prometheus/extraScrapeConfigs.yaml

+ 10 - 1
ui/docker-entrypoint.sh

@@ -1,6 +1,8 @@
 #!/bin/sh
 set -e
 
+cp -rv /opt/ui/dist/* /var/www
+
 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
@@ -9,9 +11,16 @@ else
     sed -i "s^{PLACEHOLDER_BASE_URL}^$BASE_URL^g" /var/www/*.js
 fi
 
+# export your OPENCOST_FOOTER_CONTENT='<a href="https://opencost.io">OpenCost</a>' in your Dockerfile to set
+if [[ ! -z "$OPENCOST_FOOTER_CONTENT" ]]; then
+    sed -i "s^PLACEHOLDER_FOOTER_CONTENT^$OPENCOST_FOOTER_CONTENT^g" /var/www/*.js
+else
+    sed -i "s^PLACEHOLDER_FOOTER_CONTENT^OpenCost version: $VERSION ($HEAD)^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)"
+echo "Starting OpenCost UI version $VERSION ($HEAD)"
 
 # Run the parent (nginx) container's entrypoint script
 exec /docker-entrypoint.sh "$@"

+ 98 - 11
ui/package-lock.json

@@ -10,21 +10,22 @@
       "hasInstallScript": true,
       "license": "Apache-2.0",
       "dependencies": {
-        "@babel/runtime": "^7.23.9",
-        "@date-io/core": "^1.3.13",
-        "@date-io/date-fns": "^1.3.13",
-        "@material-ui/core": "^4.11.3",
+        "material-design-icons-iconfont": "^6.1.0",
+        "axios": "^1.6.0",
         "@material-ui/icons": "^4.11.2",
         "@material-ui/pickers": "^3.3.10",
-        "@material-ui/styles": "^4.11.5",
-        "axios": "^1.6.0",
+        "html-to-react": "^1.7.0",
+        "@babel/runtime": "^7.23.9",
         "date-fns": "^2.30.0",
-        "material-design-icons-iconfont": "^6.1.0",
-        "prop-types": "^15.7.2",
-        "react": "^17.0.1",
         "react-dom": "^17.0.1",
+        "@material-ui/core": "^4.11.3",
+        "recharts": "^2.2.0",
         "react-router-dom": "^5.2.0",
-        "recharts": "^2.2.0"
+        "@date-io/core": "^1.3.13",
+        "@material-ui/styles": "^4.11.5",
+        "react": "^17.0.1",
+        "@date-io/date-fns": "^1.3.13",
+        "prop-types": "^15.7.2"
       },
       "devDependencies": {
         "@babel/core": "^7.13.10",
@@ -1032,6 +1033,19 @@
         "node": ">=10"
       }
     },
+    "node_modules/html-to-react/node_modules/dom-serializer": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+      "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+      "dependencies": {
+        "domelementtype": "^2.3.0",
+        "domhandler": "^5.0.2",
+        "entities": "^4.2.0"
+      },
+      "funding": {
+        "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+      }
+    },
     "node_modules/@parcel/reporter-cli": {
       "version": "2.11.0",
       "resolved": "https://registry.npmjs.org/@parcel/reporter-cli/-/reporter-cli-2.11.0.tgz",
@@ -1109,6 +1123,20 @@
         "node": ">=6.9.0"
       }
     },
+    "node_modules/html-to-react/node_modules/domhandler": {
+      "version": "5.0.3",
+      "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+      "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+      "dependencies": {
+        "domelementtype": "^2.3.0"
+      },
+      "engines": {
+        "node": ">= 4"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/domhandler?sponsor=1"
+      }
+    },
     "node_modules/@parcel/workers": {
       "version": "2.11.0",
       "resolved": "https://registry.npmjs.org/@parcel/workers/-/workers-2.11.0.tgz",
@@ -2103,6 +2131,11 @@
       "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
       "dev": true
     },
+    "node_modules/lodash.camelcase": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
+      "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="
+    },
     "node_modules/to-fast-properties": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
@@ -3948,6 +3981,19 @@
         "react": ">=15"
       }
     },
+    "node_modules/html-to-react/node_modules/domutils": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz",
+      "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==",
+      "dependencies": {
+        "dom-serializer": "^2.0.0",
+        "domelementtype": "^2.3.0",
+        "domhandler": "^5.0.3"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/domutils?sponsor=1"
+      }
+    },
     "node_modules/@babel/helper-hoist-variables": {
       "version": "7.22.5",
       "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz",
@@ -4545,6 +4591,19 @@
         "node": ">=8.0.0"
       }
     },
+    "node_modules/html-to-react": {
+      "version": "1.7.0",
+      "resolved": "https://registry.npmjs.org/html-to-react/-/html-to-react-1.7.0.tgz",
+      "integrity": "sha512-b5HTNaTGyOj5GGIMiWVr1k57egAZ/vGy0GGefnCQ1VW5hu9+eku8AXHtf2/DeD95cj/FKBKYa1J7SWBOX41yUQ==",
+      "dependencies": {
+        "domhandler": "^5.0",
+        "htmlparser2": "^9.0",
+        "lodash.camelcase": "^4.3.0"
+      },
+      "peerDependencies": {
+        "react": "^0.13.0 || ^0.14.0 || >=15"
+      }
+    },
     "node_modules/@swc/core-darwin-x64": {
       "version": "1.3.107",
       "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.3.107.tgz",
@@ -4714,7 +4773,6 @@
       "version": "2.3.0",
       "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
       "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
-      "dev": true,
       "funding": [
         {
           "type": "github",
@@ -5546,6 +5604,24 @@
         "url": "https://opencollective.com/parcel"
       }
     },
+    "node_modules/html-to-react/node_modules/htmlparser2": {
+      "version": "9.1.0",
+      "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz",
+      "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==",
+      "funding": [
+        "https://github.com/fb55/htmlparser2?sponsor=1",
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fb55"
+        }
+      ],
+      "dependencies": {
+        "domelementtype": "^2.3.0",
+        "domhandler": "^5.0.3",
+        "domutils": "^3.1.0",
+        "entities": "^4.5.0"
+      }
+    },
     "node_modules/@parcel/transformer-svg": {
       "version": "2.11.0",
       "resolved": "https://registry.npmjs.org/@parcel/transformer-svg/-/transformer-svg-2.11.0.tgz",
@@ -5783,6 +5859,17 @@
         "node": ">=10"
       }
     },
+    "node_modules/html-to-react/node_modules/entities": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+      "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
     "node_modules/@parcel/optimizer-svgo/node_modules/css-select": {
       "version": "4.3.0",
       "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz",

+ 1 - 0
ui/package.json

@@ -23,6 +23,7 @@
     "@material-ui/styles": "^4.11.5",
     "axios": "^1.6.0",
     "date-fns": "^2.30.0",
+    "html-to-react": "^1.7.0",
     "material-design-icons-iconfont": "^6.1.0",
     "prop-types": "^15.7.2",
     "react": "^17.0.1",

+ 7 - 2
ui/src/Reports.js

@@ -22,6 +22,7 @@ import AllocationReport from "./components/allocationReport";
 import Controls from "./components/Controls";
 import Header from "./components/Header";
 import Page from "./components/Page";
+import Footer from "./components/Footer";
 import Subtitle from "./components/Subtitle";
 import Warnings from "./components/Warnings";
 import AllocationService from "./services/allocation";
@@ -48,11 +49,14 @@ const aggregationOptions = [
   { name: "Cluster", value: "cluster" },
   { name: "Node", value: "node" },
   { name: "Namespace", value: "namespace" },
-  { name: "Controller kind", value: "controllerKind" },
+  { name: "Controller Kind", value: "controllerKind" },
   { name: "Controller", value: "controller" },
+  { name: "DaemonSet", value: "daemonset" },
+  { name: "Deployment", value: "deployment" },
+  { name: "Job", value: "job" },
   { name: "Service", value: "service" },
+  { name: "StatefulSet", value: "statefulset" },
   { name: "Pod", value: "pod" },
-  { name: "Deployment", value: "deployment" },
   { name: "Container", value: "container" },
 ];
 
@@ -302,6 +306,7 @@ const ReportsPage = () => {
           )}
         </Paper>
       )}
+      <Footer/>
     </Page>
   );
 };

+ 2 - 0
ui/src/cloudCostReports.js

@@ -1,6 +1,7 @@
 import * as React from "react";
 import Page from "./components/Page";
 import Header from "./components/Header";
+import Footer from "./components/Footer";
 import IconButton from "@material-ui/core/IconButton";
 import RefreshIcon from "@material-ui/icons/Refresh";
 import { makeStyles } from "@material-ui/styles";
@@ -328,6 +329,7 @@ const CloudCostReports = () => {
           )}
         </Paper>
       )}
+      <Footer/>
     </Page>
   );
 };

+ 13 - 0
ui/src/components/Footer.js

@@ -0,0 +1,13 @@
+import {Parser as HtmlToReactParser} from 'html-to-react'
+
+// Footer could be HTML, so we need to parse it.
+const Footer = () => {
+  const content = '<div align="right"><br/>PLACEHOLDER_FOOTER_CONTENT</div>';
+  const htmlToReactParser = new HtmlToReactParser();
+  const parsedContent = htmlToReactParser.parse(content);
+  return (
+    parsedContent
+    )
+  }
+
+export default Footer;