Browse Source

Merge branch 'develop' into sth/cloud-cost-clean-up

Sean Holcomb 2 years ago
parent
commit
c5981a74ad

+ 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

+ 13 - 12
go.mod

@@ -10,10 +10,10 @@ require (
 	cloud.google.com/go/compute/metadata v0.2.3
 	cloud.google.com/go/storage v1.30.1
 	github.com/Azure/azure-pipeline-go v0.2.3
-	github.com/Azure/azure-sdk-for-go v65.0.0+incompatible
-	github.com/Azure/azure-sdk-for-go/sdk/azcore v1.5.0
-	github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.2.2
-	github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.0.0
+	github.com/Azure/azure-sdk-for-go v68.0.0+incompatible
+	github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.2
+	github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.5.1
+	github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.3.0
 	github.com/Azure/azure-storage-blob-go v0.15.0
 	github.com/Azure/go-autorest/autorest v0.11.28
 	github.com/Azure/go-autorest/autorest/adal v0.9.21
@@ -31,7 +31,7 @@ require (
 	github.com/aws/smithy-go v1.19.0
 	github.com/davecgh/go-spew v1.1.1
 	github.com/getsentry/sentry-go v0.25.0
-	github.com/google/uuid v1.4.0
+	github.com/google/uuid v1.6.0
 	github.com/jszwec/csvutil v1.2.1
 	github.com/julienschmidt/httprouter v1.3.0
 	github.com/kubecost/events v0.0.6
@@ -68,7 +68,7 @@ require (
 	cloud.google.com/go v0.110.10 // indirect
 	cloud.google.com/go/compute v1.23.3 // indirect
 	cloud.google.com/go/iam v1.1.5 // indirect
-	github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0 // indirect
+	github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2 // indirect
 	github.com/Azure/go-autorest v14.2.0+incompatible // indirect
 	github.com/Azure/go-autorest/autorest/azure/cli v0.4.5 // indirect
 	github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect
@@ -76,7 +76,7 @@ require (
 	github.com/Azure/go-autorest/autorest/validation v0.3.1 // indirect
 	github.com/Azure/go-autorest/logger v0.2.1 // indirect
 	github.com/Azure/go-autorest/tracing v0.6.0 // indirect
-	github.com/AzureAD/microsoft-authentication-library-for-go v0.9.0 // indirect
+	github.com/AzureAD/microsoft-authentication-library-for-go v1.2.1 // indirect
 	github.com/PuerkitoBio/purell v1.1.1 // indirect
 	github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
 	github.com/andybalholm/brotli v1.0.5 // indirect
@@ -111,6 +111,7 @@ require (
 	github.com/gofrs/uuid v4.2.0+incompatible // indirect
 	github.com/gogo/protobuf v1.3.2 // indirect
 	github.com/golang-jwt/jwt/v4 v4.5.0 // indirect
+	github.com/golang-jwt/jwt/v5 v5.2.0 // indirect
 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
 	github.com/golang/protobuf v1.5.3 // indirect
 	github.com/golang/snappy v0.0.4 // indirect
@@ -150,7 +151,7 @@ require (
 	github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect
 	github.com/pelletier/go-toml v1.9.3 // indirect
 	github.com/pierrec/lz4/v4 v4.1.15 // indirect
-	github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect
+	github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/prometheus/procfs v0.11.1 // indirect
 	github.com/rs/xid v1.4.0 // indirect
@@ -168,11 +169,11 @@ require (
 	go.opentelemetry.io/otel/metric v1.21.0 // indirect
 	go.opentelemetry.io/otel/trace v1.21.0 // indirect
 	go.uber.org/atomic v1.10.0 // indirect
-	golang.org/x/crypto v0.17.0 // indirect
+	golang.org/x/crypto v0.18.0 // indirect
 	golang.org/x/mod v0.10.0 // indirect
-	golang.org/x/net v0.19.0 // indirect
-	golang.org/x/sys v0.15.0 // indirect
-	golang.org/x/term v0.15.0 // indirect
+	golang.org/x/net v0.20.0 // indirect
+	golang.org/x/sys v0.16.0 // indirect
+	golang.org/x/term v0.16.0 // indirect
 	golang.org/x/time v0.5.0 // indirect
 	golang.org/x/tools v0.9.1 // indirect
 	golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect

+ 29 - 25
go.sum

@@ -55,16 +55,18 @@ cloud.google.com/go/storage v1.30.1/go.mod h1:NfxhC0UJE1aXSx7CIIbCf7y9HKT7Biccwk
 dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
 github.com/Azure/azure-pipeline-go v0.2.3 h1:7U9HBg1JFK3jHl5qmo4CTZKFTVgMwdFHMVtCdfBE21U=
 github.com/Azure/azure-pipeline-go v0.2.3/go.mod h1:x841ezTBIMG6O3lAcl8ATHnsOPVl2bqk7S3ta6S6u4k=
-github.com/Azure/azure-sdk-for-go v65.0.0+incompatible h1:HzKLt3kIwMm4KeJYTdx9EbjRYTySD/t8i1Ee/W5EGXw=
-github.com/Azure/azure-sdk-for-go v65.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc=
-github.com/Azure/azure-sdk-for-go/sdk/azcore v1.5.0 h1:xGLAFFd9D3iLGxYiUGPdITSzsFmU1K8VtfuUHWAoN7M=
-github.com/Azure/azure-sdk-for-go/sdk/azcore v1.5.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
-github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.2.2 h1:uqM+VoHjVH6zdlkLF2b6O0ZANcHoj3rO0PoQ3jglUJA=
-github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.2.2/go.mod h1:twTKAa1E6hLmSDjLhaCkbTMQKc7p/rNLU40rLxGEOCI=
-github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0 h1:sXr+ck84g/ZlZUOZiNELInmMgOsuGwdjjVkEIde0OtY=
-github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM=
-github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.0.0 h1:u/LLAOFgsMv7HmNL4Qufg58y+qElGOt5qv0z1mURkRY=
-github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.0.0/go.mod h1:2e8rMJtl2+2j+HXbTBwnyGpm5Nou7KhvSfxOq8JpTag=
+github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU=
+github.com/Azure/azure-sdk-for-go v68.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.2 h1:c4k2FIYIh4xtwqrQwV0Ct1v5+ehlNXj5NI/MWVsiTkQ=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.2/go.mod h1:5FDJtLEO/GxwNgUxbwrY3LP0pEoThTQJtk2oysdXHxM=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.5.1 h1:sO0/P7g68FrryJzljemN+6GTssUXdANk6aJ7T1ZxnsQ=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.5.1/go.mod h1:h8hyGFDsU5HMivxiS2iYFZsgDbU9OnnJ163x5UGVKYo=
+github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2 h1:LqbJ/WzJUwBf8UiaSzgX7aMclParm9/5Vgp+TY51uBQ=
+github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2/go.mod h1:yInRyqWXAuaPrgI7p70+lDDgh3mlBohis29jGMISnmc=
+github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.5.0 h1:AifHbc4mg0x9zW52WOpKbsHaDKuRhlI7TVl47thgQ70=
+github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.5.0/go.mod h1:T5RfihdXtBDxt1Ch2wobif3TvzTdumDy29kahv6AV9A=
+github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.3.0 h1:IfFdxTUDiV58iZqPKgyWiz4X4fCxZeQ1pTQPImLYXpY=
+github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.3.0/go.mod h1:SUZc9YRRHfx2+FAQKNDGrssXehqLpxmwRv2mC/5ntj4=
 github.com/Azure/azure-storage-blob-go v0.15.0 h1:rXtgp8tN1p29GvpGgfJetavIG0V7OgcSXPpwp3tx6qk=
 github.com/Azure/azure-storage-blob-go v0.15.0/go.mod h1:vbjsVbX0dlxnRc4FFMPsS9BsJWPcne7GB7onqlPvz58=
 github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs=
@@ -93,8 +95,8 @@ github.com/Azure/go-autorest/logger v0.2.1 h1:IG7i4p/mDa2Ce4TRyAO8IHnVhAVF3RFU+Z
 github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8=
 github.com/Azure/go-autorest/tracing v0.6.0 h1:TYi4+3m5t6K48TGI9AUdb+IzbnSxvnvUMfuitfgcfuo=
 github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU=
-github.com/AzureAD/microsoft-authentication-library-for-go v0.9.0 h1:UE9n9rkJF62ArLb1F3DEjRt8O3jLwMWdSoypKV4f3MU=
-github.com/AzureAD/microsoft-authentication-library-for-go v0.9.0/go.mod h1:kgDmCTgBzIEPFElEF+FK0SdjAor06dRq2Go927dnQ6o=
+github.com/AzureAD/microsoft-authentication-library-for-go v1.2.1 h1:DzHpqpoJVaCgOUdVHxE8QB52S6NiVdDQvGlny1qvPqA=
+github.com/AzureAD/microsoft-authentication-library-for-go v1.2.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
 github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c h1:RGWPOewvKIROun94nF7v2cua9qP+thov/7M50KEoeSU=
@@ -249,6 +251,8 @@ github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzw
 github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
 github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
 github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
+github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
+github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
 github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
 github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
 github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -329,8 +333,8 @@ github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
 github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
 github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
-github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs=
 github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
 github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
@@ -482,8 +486,8 @@ github.com/pierrec/lz4/v4 v4.1.15 h1:MO0/ucJhngq7299dKLwIMtgTfbkoSPF6AoMYDd8Q4q0
 github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
 github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
 github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
-github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU=
-github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -610,8 +614,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
 golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
 golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
-golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
-golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
+golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
+golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -692,8 +696,8 @@ golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1
 golang.org/x/net v0.0.0-20210610132358-84b48f89b13b/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
-golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
+golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
+golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -766,16 +770,16 @@ golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
-golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
+golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
-golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4=
-golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
+golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE=
+golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=

+ 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

+ 1 - 1
pkg/cloud/azure/storageauthorizer.go

@@ -116,7 +116,7 @@ func (ah *AuthorizerHolder) GetBlobClient(serviceURL string) (*azblob.Client, er
 	// Create a default request pipeline using your storage account name and account key.
 	cred, err := ah.GetCredential()
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("error retrieving credentials: %w", err)
 	}
 
 	client, err := azblob.NewClient(serviceURL, cred, nil)

+ 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

+ 288 - 154
pkg/cloud/config/controller.go

@@ -2,9 +2,13 @@ package config
 
 import (
 	"fmt"
+	"os"
+	"path/filepath"
 	"sync"
 	"time"
 
+	"github.com/opencost/opencost/core/pkg/log"
+	"github.com/opencost/opencost/core/pkg/util/json"
 	"github.com/opencost/opencost/core/pkg/util/timeutil"
 	"github.com/opencost/opencost/pkg/cloud"
 	"github.com/opencost/opencost/pkg/cloud/models"
@@ -12,36 +16,14 @@ import (
 	"github.com/opencost/opencost/pkg/env"
 )
 
-// configID identifies the source and the ID of a configuration to handle duplicate configs from multiple sources
-type configID struct {
-	source ConfigSource
-	key    string
-}
-
-func (cid configID) Equals(that configID) bool {
-	return cid.source == that.source && cid.key == that.key
-}
-
-func newConfigID(source, key string) configID {
-	return configID{
-		source: GetConfigSource(source),
-		key:    key,
-	}
-}
-
-type Status struct {
-	Source ConfigSource
-	Key    string
-	Active bool
-	Valid  bool
-	Config cloud.KeyedConfig
-}
+const configFile = "cloud-configurations.json"
 
 // Controller manages the cloud.Config using config Watcher(s) to track various configuration
 // methods. To do this it has a map of config watchers mapped on configuration source and a list Observers that it updates
 // upon any change detected from the config watchers.
 type Controller struct {
-	statuses  map[configID]*Status
+	path      string
+	lock      sync.RWMutex
 	observers []Observer
 	watchers  map[ConfigSource]cloud.KeyedConfigWatcher
 }
@@ -49,18 +31,17 @@ 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 {
 		watchers = GetCloudBillingWatchers(nil)
 	}
 	ic := &Controller{
-		statuses: make(map[configID]*Status),
+		path:     filepath.Join(env.GetConfigPathWithDefault(env.DefaultConfigMountPath), configFile),
 		watchers: watchers,
 	}
 
-	ic.load()
 	ic.pullWatchers()
 
 	go func() {
@@ -79,38 +60,217 @@ func NewController(cp models.Provider) *Controller {
 	return ic
 }
 
-func (c *Controller) EnableConfig(key, source string) error {
-	cID := newConfigID(source, key)
-	cs, ok := c.statuses[cID]
+// pullWatchers retrieve configs from watchers and update configs according to priority of sources
+func (c *Controller) pullWatchers() {
+	c.lock.Lock()
+	defer c.lock.Unlock()
+	statuses, err := c.load()
+	if err != nil {
+		log.Errorf("failed to load statuses when pulling watchers %s", err)
+		statuses = Statuses{}
+	}
+	for source, watcher := range c.watchers {
+		watcherConfsByKey := map[string]cloud.KeyedConfig{}
+		for _, wConf := range watcher.GetConfigs() {
+			watcherConfsByKey[wConf.Key()] = wConf
+		}
+
+		// remove existing configs that are no longer present in the source
+		for _, status := range statuses.List() {
+			if status.Source == source {
+				if _, ok := watcherConfsByKey[status.Key]; !ok {
+					err := c.deleteConfig(status.Key, status.Source, statuses)
+					if err != nil {
+						log.Errorf("Conrtoller: pullWatchers: %s", err.Error())
+					}
+				}
+
+			}
+		}
+
+		for key, conf := range watcherConfsByKey {
+
+			// Check existing configs for matching key and source
+			if existingStatus, ok := statuses.Get(key, source); ok {
+				// if config has not changed continue
+				if existingStatus.Config.Equals(conf) {
+					continue
+				}
+				// remove the existing config
+				err := c.deleteConfig(key, source, statuses)
+				if err != nil {
+					log.Errorf("Conrtoller: pullWatchers: %s", err.Error())
+				}
+
+			}
+
+			err := conf.Validate()
+			valid := err == nil
+
+			configType, err := ConfigTypeFromConfig(conf)
+			if err != nil {
+				log.Errorf("failed to get config type for config with key: %s", conf.Key())
+				continue
+			}
+
+			status := Status{
+				Key:        key,
+				Source:     source,
+				Active:     valid, // if valid, then new config will be active
+				Valid:      valid,
+				ConfigType: configType,
+				Config:     conf,
+			}
+
+			// handle a config with a new unique key for a source or an update config from a source which was inactive before
+			if valid {
+				for _, matchStat := range statuses.List() {
+					//// skip matching configs
+					//if matchID.Equals(cID) {
+					//	continue
+					//}
+
+					if matchStat.Active {
+						// if source is non-multi-cloud disable all other non-multi-cloud sourced configs
+						if source == HelmSource || source == ConfigFileSource {
+							if matchStat.Source == HelmSource || matchStat.Source == ConfigFileSource {
+								matchStat.Active = false
+								c.broadcastRemoveConfig(matchStat.Key)
+							}
+						}
+
+						// check for configs with the same key that are active
+						if matchStat.Key == key {
+							// If source has higher priority disable other active configs
+							matchStat.Active = false
+							c.broadcastRemoveConfig(matchStat.Key)
+						}
+					}
+				}
+			}
+
+			// update config and put to observers if active
+			statuses.Insert(&status)
+			if status.Active {
+				c.broadcastAddConfig(conf)
+			}
+			err = c.save(statuses)
+			if err != nil {
+				log.Errorf("failed to save statuses %s", err.Error())
+			}
+		}
+	}
+}
+
+// CreateConfig adds a new config to status with a source of ConfigControllerSource
+// It will disable any config with the same key
+// fails if there is an existing config with the same key and source
+func (c *Controller) CreateConfig(conf cloud.KeyedConfig) error {
+	c.lock.Lock()
+	defer c.lock.Unlock()
+
+	err := conf.Validate()
+	if err != nil {
+		return fmt.Errorf("provided configuration was invalid: %w", err)
+	}
+
+	statuses, err := c.load()
+	if err != nil {
+		return fmt.Errorf("failed to load statuses")
+	}
+	source := ConfigControllerSource
+	key := conf.Key()
+
+	_, ok := statuses.Get(key, source)
+	if ok {
+		return fmt.Errorf("config with key %s from source %s already exist", key, source.String())
+	}
+
+	configType, err := ConfigTypeFromConfig(conf)
+	if err != nil {
+		return fmt.Errorf("config did not have recoginzed config: %w", err)
+	}
+
+	statuses.Insert(&Status{
+		Key:        key,
+		Source:     source,
+		Valid:      true,
+		Active:     true,
+		ConfigType: configType,
+		Config:     conf,
+	})
+
+	// check for configurations with the same configuration key that are already active.
+	for _, confStat := range statuses.List() {
+		if confStat.Key != key || confStat.Source == source {
+			continue
+		}
+
+		// if active disable
+		if confStat.Active == true {
+			confStat.Active = false
+			c.broadcastRemoveConfig(key)
+		}
+
+	}
+
+	c.broadcastAddConfig(conf)
+	err = c.save(statuses)
+	if err != nil {
+		return fmt.Errorf("failed to save statues: %w", err)
+	}
+	return nil
+}
+
+// EnableConfig enables a config with the given key and source, and disables any config with a matching key
+func (c *Controller) EnableConfig(key, sourceStr string) error {
+	c.lock.Lock()
+	defer c.lock.Unlock()
+
+	statuses, err := c.load()
+	if err != nil {
+		return fmt.Errorf("failed to load statuses")
+	}
+
+	source := GetConfigSource(sourceStr)
+	cs, ok := statuses.Get(key, source)
 	if !ok {
-		return fmt.Errorf("Controller: EnableConfig: config with key %s from source %s does not exist", key, source)
+		return fmt.Errorf("config with key %s from source %s does not exist", key, sourceStr)
 	}
 	if cs.Active {
-		return fmt.Errorf("Controller: EnableConfig: config with key %s from source %s is already active", key, source)
+		return fmt.Errorf("config with key %s from source %s is already active", key, sourceStr)
 	}
 
 	// check for configurations with the same configuration key that are already active.
-	for confID, confStat := range c.statuses {
-		if confID.key != key || confID.source == cID.source {
+	for _, confStat := range statuses.List() {
+		if confStat.Key != key || confStat.Source == source {
 			continue
 		}
 
 		// if active disable
 		if confStat.Active == true {
 			confStat.Active = false
+			c.broadcastRemoveConfig(key)
 		}
+
 	}
 
 	cs.Active = true
-	c.putConfig(cs.Config)
-	c.save()
+	c.broadcastAddConfig(cs.Config)
+	c.save(statuses)
 	return nil
 }
 
 // DisableConfig updates an config status if it was enabled
-func (c *Controller) DisableConfig(key, source string) error {
-	iID := newConfigID(source, key)
-	is, ok := c.statuses[iID]
+func (c *Controller) DisableConfig(key, sourceStr string) error {
+	c.lock.Lock()
+	defer c.lock.Unlock()
+	statuses, err := c.load()
+	if err != nil {
+		return fmt.Errorf("failed to load statuses")
+	}
+	source := GetConfigSource(sourceStr)
+	is, ok := statuses.Get(key, source)
 	if !ok {
 		return fmt.Errorf("Controller: DisableConfig: config with key %s from source %s does not exist", key, source)
 	}
@@ -119,122 +279,84 @@ func (c *Controller) DisableConfig(key, source string) error {
 	}
 
 	is.Active = false
-	c.deleteConfig(iID.key)
-	c.save()
+	c.broadcastRemoveConfig(key)
+	c.save(statuses)
+	return nil
+}
+
+// DeleteConfig removes a config from the statuses and deletes the config on all observers if it was active
+// This can only be used on configs with ConfigControllerSource
+func (c *Controller) DeleteConfig(key, sourceStr string) error {
+	c.lock.Lock()
+	defer c.lock.Unlock()
+	source := GetConfigSource(sourceStr)
+	if source != ConfigControllerSource {
+		return fmt.Errorf("controller does not own config with key %s from source %s, manage this config at its source", key, source.String())
+	}
+
+	statuses, err := c.load()
+	if err != nil {
+		return fmt.Errorf("failed to load statuses")
+	}
+
+	err = c.deleteConfig(key, source, statuses)
+	if err != nil {
+		return fmt.Errorf("Controller: DeleteConfig: %w", err)
+	}
 	return nil
 }
 
-// DeleteConfig removes an config from the statuses and deletes the config on all observers if it was active
-func (c *Controller) DeleteConfig(key, source string) error {
-	id := newConfigID(source, key)
-	is, ok := c.statuses[id]
+func (c *Controller) deleteConfig(key string, source ConfigSource, statuses Statuses) error {
+	is, ok := statuses.Get(key, source)
 	if !ok {
-		return fmt.Errorf("Controller: DisableConfig: config with key %s from source %s does not exist", key, source)
+		return fmt.Errorf("config with key %s from source %s does not exist", key, source.String())
 	}
 
 	// delete config on observers if active
 	if is.Active {
-		c.deleteConfig(id.key)
+		c.broadcastRemoveConfig(key)
 	}
-	delete(c.statuses, id)
-	c.save()
+	delete(statuses[source], key)
+	c.save(statuses)
 	return nil
 }
 
-// pullWatchers retrieve configs from watchers and update configs according to priority of sources
-func (c *Controller) pullWatchers() {
-
-	for source, watcher := range c.watchers {
-		for _, conf := range watcher.GetConfigs() {
-			key := conf.Key()
-			cID := configID{
-				source: source,
-				key:    key,
-			}
-
-			err := conf.Validate()
-			valid := err == nil
-
-			status := Status{
-				Key:    key,
-				Source: source,
-				Active: valid, // active if valid, for now
-				Valid:  valid,
-				Config: conf,
-			}
-
-			// Check existing configs for matching key and source
-			if existingStatus, ok := c.statuses[cID]; ok {
-				// if config has not changed continue
-				if existingStatus.Config.Equals(conf) {
-					continue
-				}
-				// if existing CS is active then it should be replaced by the updated config
-				if existingStatus.Active {
-					if status.Valid {
-						c.putConfig(conf)
-					} else {
-						// if active config is being overwritten by an invalid one, delete the config, as it will not be active
-						c.deleteConfig(key)
-					}
-					c.statuses[cID] = &status
-					continue
-				}
-			}
+func (c *Controller) load() (Statuses, error) {
+	raw, err := os.ReadFile(c.path)
+	if err != nil {
+		return nil, fmt.Errorf("ConfigController: failed to load config statuses from file: %w", err)
+	}
 
-			// At this point we know that the config from this watcher has changed
+	statuses := Statuses{}
+	err = json.Unmarshal(raw, &statuses)
+	if err != nil {
+		return nil, fmt.Errorf("ConfigController: failed to marshal config statuses: %s", err.Error())
+	}
 
-			// handle an config with a new unique key for a source or an update config from a source which was inactive before
-			if valid {
-				for matchID, matchCS := range c.statuses {
-					// skip matching configs
-					if matchID.Equals(cID) {
-						continue
-					}
+	return statuses, nil
+}
 
-					if matchCS.Active {
-						// if source is non-multi-cloud disable all other non-multi-cloud sourced configs
-						if cID.source == HelmSource || cID.source == ConfigFileSource {
-							if matchID.source == HelmSource || matchID.source == ConfigFileSource {
-								matchCS.Active = false
-								c.deleteConfig(matchID.key)
-							}
-						}
+func (c *Controller) save(statuses Statuses) error {
 
-						// check for configs with the same key that are active
-						if matchID.key == key {
-							// If source has higher priority disable other active configs
-							matchCS.Active = false
-							c.deleteConfig(matchID.key)
-						}
-					}
-				}
-			}
-
-			// update config and put to observers if active
-			c.statuses[cID] = &status
-			if status.Active {
-				c.putConfig(conf)
-			}
-		}
+	raw, err := json.Marshal(statuses)
+	if err != nil {
+		return fmt.Errorf("ConfigController: failed to marshal config statuses: %s", err)
 	}
-}
 
-// todo implement when building config api and persistence is necessary
-func (c *Controller) load() {}
+	err = os.WriteFile(c.path, raw, 0644)
+	if err != nil {
+		return fmt.Errorf("ConfigController: failed to save config statuses to file: %s", err)
+	}
 
-// todo implement when building config api and persistence is necessary
-func (c *Controller) save() {}
+	return nil
+}
 
 func (c *Controller) ExportConfigs(key string) (*Configurations, error) {
+	c.lock.RLock()
+	defer c.lock.RUnlock()
 	configs := new(Configurations)
 
-	activeConfigs := make(map[string]cloud.Config)
-	for iID, cs := range c.statuses {
-		if cs.Active {
-			activeConfigs[iID.key] = cs.Config
-		}
-	}
+	activeConfigs := c.getActiveConfigs()
 	if key != "" {
 		conf, ok := activeConfigs[key]
 		if !ok {
@@ -259,17 +381,21 @@ func (c *Controller) ExportConfigs(key string) (*Configurations, error) {
 }
 
 func (c *Controller) getActiveConfigs() map[string]cloud.KeyedConfig {
-	bi := make(map[string]cloud.KeyedConfig)
-	for iID, cs := range c.statuses {
+	activeConfigs := make(map[string]cloud.KeyedConfig)
+	statuses, err := c.load()
+	if err != nil {
+		log.Errorf("GetStatus: failed to load cloud statuses")
+	}
+	for _, cs := range statuses.List() {
 		if cs.Active {
-			bi[iID.key] = cs.Config
+			activeConfigs[cs.Key] = cs.Config
 		}
 	}
-	return bi
+	return activeConfigs
 }
 
-// deleteConfig ask observers to remove and stop all processes related to a configuration with a given key
-func (c *Controller) deleteConfig(key string) {
+// broadcastRemoveConfig ask observers to remove and stop all processes related to a configuration with a given key
+func (c *Controller) broadcastRemoveConfig(key string) {
 	var wg sync.WaitGroup
 	for _, obs := range c.observers {
 		observer := obs
@@ -282,30 +408,38 @@ func (c *Controller) deleteConfig(key string) {
 	wg.Wait()
 }
 
+// broadcastAddConfig gives observers a new config to handle
+func (c *Controller) broadcastAddConfig(conf cloud.KeyedConfig) {
+	var wg sync.WaitGroup
+	for _, obs := range c.observers {
+		observer := obs
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			observer.PutConfig(conf)
+		}()
+	}
+	wg.Wait()
+}
+
 // RegisterObserver gives out the current active list configs and adds the observer to the push list
 func (c *Controller) RegisterObserver(obs Observer) {
+	c.lock.Lock()
+	defer c.lock.Unlock()
 	obs.SetConfigs(c.getActiveConfigs())
 	c.observers = append(c.observers, obs)
 }
 
 func (c *Controller) GetStatus() []Status {
+	c.lock.RLock()
+	defer c.lock.RUnlock()
 	var status []Status
-	for _, intStat := range c.statuses {
+	statuses, err := c.load()
+	if err != nil {
+		log.Errorf("GetStatus: failed to load cloud statuses")
+	}
+	for _, intStat := range statuses.List() {
 		status = append(status, *intStat)
 	}
 	return status
 }
-
-// putConfig gives observers a new config to handle
-func (c *Controller) putConfig(conf cloud.KeyedConfig) {
-	var wg sync.WaitGroup
-	for _, obs := range c.observers {
-		observer := obs
-		wg.Add(1)
-		go func() {
-			defer wg.Done()
-			observer.PutConfig(conf)
-		}()
-	}
-	wg.Wait()
-}

+ 81 - 16
pkg/cloud/config/controller_handlers.go

@@ -1,11 +1,19 @@
 package config
 
 import (
+	"bytes"
+	"encoding/json"
 	"fmt"
+	"io"
 	"net/http"
+	"strings"
 
 	"github.com/julienschmidt/httprouter"
 	proto "github.com/opencost/opencost/core/pkg/protocol"
+	"github.com/opencost/opencost/pkg/cloud"
+	"github.com/opencost/opencost/pkg/cloud/aws"
+	"github.com/opencost/opencost/pkg/cloud/azure"
+	"github.com/opencost/opencost/pkg/cloud/gcp"
 	"github.com/opencost/opencost/pkg/env"
 )
 
@@ -51,6 +59,77 @@ func (c *Controller) GetExportConfigHandler() func(w http.ResponseWriter, r *htt
 	}
 }
 
+// GetEnableConfigHandler creates a handler from a http request which enables an integration via the integrationController
+func (c *Controller) GetAddConfigHandler() func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+	// perform basic checks to ensure that the pipeline can be accessed
+	fn := c.cloudCostChecks()
+	if fn != nil {
+		return fn
+	}
+
+	// Return valid handler func
+	return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+		w.Header().Set("Content-Type", "application/json")
+
+		configType := r.URL.Query().Get("type")
+
+		config, err := parseConfig(configType, r.Body)
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
+		}
+
+		err = c.CreateConfig(config)
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
+		}
+
+		protocol.WriteData(w, fmt.Sprintf("Successfully added integration with key %s", config.Key()))
+	}
+}
+
+func parseConfig(configType string, body io.Reader) (cloud.KeyedConfig, error) {
+	buf := new(bytes.Buffer)
+	_, err := buf.ReadFrom(body)
+	if err != nil {
+		return nil, fmt.Errorf("failed to read body: %w", err)
+	}
+	bytes := buf.Bytes()
+	switch strings.ToLower(configType) {
+	case S3ConfigType:
+		config := &aws.S3Configuration{}
+		err = json.Unmarshal(bytes, config)
+		if err != nil {
+			return nil, fmt.Errorf("error unmarshalling S3 Configuration: %w", err)
+		}
+		return config, nil
+	case AthenaConfigType:
+		config := &aws.AthenaConfiguration{}
+		err = json.Unmarshal(bytes, config)
+		if err != nil {
+			return nil, fmt.Errorf("error unmarshalling Athena Configuration: %w", err)
+		}
+		return config, nil
+	case BigQueryConfigType:
+		config := &gcp.BigQueryConfiguration{}
+		err = json.Unmarshal(bytes, config)
+		if err != nil {
+			return nil, fmt.Errorf("error unmarshalling Big Query Configuration: %w", err)
+		}
+		return config, nil
+	case AzureStorageConfigType:
+		config := &azure.StorageConfiguration{}
+		err = json.Unmarshal(bytes, config)
+		if err != nil {
+			return nil, fmt.Errorf("error unmarshalling Azure Storage Configuration: %w", err)
+		}
+		return config, nil
+
+	}
+	return nil, fmt.Errorf("provided config type was not recognised %s", configType)
+}
+
 // GetEnableConfigHandler creates a handler from a http request which enables an integration via the integrationController
 func (c *Controller) GetEnableConfigHandler() func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
 	// perform basic checks to ensure that the pipeline can be accessed
@@ -136,25 +215,11 @@ func (c *Controller) GetDeleteConfigHandler() func(w http.ResponseWriter, r *htt
 			return
 		}
 
-		source := r.URL.Query().Get("source")
-		if source == "" {
-			http.Error(w, "required parameter 'source' is missing", http.StatusBadRequest)
-			return
-		}
-
-		err := c.DeleteConfig(integrationKey, source)
+		err := c.DeleteConfig(integrationKey, ConfigControllerSource.String())
 		if err != nil {
 			http.Error(w, err.Error(), http.StatusBadRequest)
 			return
 		}
-		protocol.WriteData(w, fmt.Sprintf("Successfully deleted integration with key %s from source %s", integrationKey, source))
-
-		for _, intStat := range c.GetStatus() {
-			if intStat.Key == integrationKey {
-				protocol.WriteData(w, fmt.Sprintf("Found addition integration with integration key %s from source %s. If you wish to delete this data do so manually or delete all integrations with matching keys", integrationKey, intStat.Source))
-				return
-			}
-		}
-		protocol.WriteData(w, fmt.Sprintf("Successfully deleted cloud cost data with key %s", integrationKey))
+		protocol.WriteData(w, fmt.Sprintf("Successfully deleted integration with key %s", integrationKey))
 	}
 }

+ 863 - 345
pkg/cloud/config/controller_test.go

@@ -1,6 +1,9 @@
 package config
 
 import (
+	"fmt"
+	"os"
+	"path/filepath"
 	"testing"
 
 	cloudconfig "github.com/opencost/opencost/pkg/cloud"
@@ -67,22 +70,24 @@ func TestIntegrationController_pullWatchers(t *testing.T) {
 			},
 			expectedStatuses: []*Status{
 				{
-					Source: HelmSource,
-					Key:    validAthenaConf.Key(),
-					Active: true,
-					Valid:  true,
-					Config: validAthenaConf,
+					Source:     HelmSource,
+					Key:        validAthenaConf.Key(),
+					Active:     true,
+					Valid:      true,
+					ConfigType: AthenaConfigType,
+					Config:     validAthenaConf,
 				},
 			},
 		},
 		"Helm Source No Change": {
 			initialStatuses: []*Status{
 				{
-					Source: HelmSource,
-					Key:    validAthenaConf.Key(),
-					Active: true,
-					Valid:  true,
-					Config: validAthenaConf,
+					Source:     HelmSource,
+					Key:        validAthenaConf.Key(),
+					Active:     true,
+					Valid:      true,
+					ConfigType: AthenaConfigType,
+					Config:     validAthenaConf,
 				},
 			},
 			configWatchers: map[ConfigSource]cloudconfig.KeyedConfigWatcher{
@@ -94,22 +99,24 @@ func TestIntegrationController_pullWatchers(t *testing.T) {
 			},
 			expectedStatuses: []*Status{
 				{
-					Source: HelmSource,
-					Key:    validAthenaConf.Key(),
-					Active: true,
-					Valid:  true,
-					Config: validAthenaConf,
+					Source:     HelmSource,
+					Key:        validAthenaConf.Key(),
+					Active:     true,
+					Valid:      true,
+					ConfigType: AthenaConfigType,
+					Config:     validAthenaConf,
 				},
 			},
 		},
 		"Helm Source Update Config": {
 			initialStatuses: []*Status{
 				{
-					Source: HelmSource,
-					Key:    validAthenaConfModifiedProperty.Key(),
-					Active: true,
-					Valid:  true,
-					Config: validAthenaConfModifiedProperty,
+					Source:     HelmSource,
+					Key:        validAthenaConfModifiedProperty.Key(),
+					Active:     true,
+					Valid:      true,
+					ConfigType: AthenaConfigType,
+					Config:     validAthenaConfModifiedProperty,
 				},
 			},
 			configWatchers: map[ConfigSource]cloudconfig.KeyedConfigWatcher{
@@ -121,22 +128,24 @@ func TestIntegrationController_pullWatchers(t *testing.T) {
 			},
 			expectedStatuses: []*Status{
 				{
-					Source: HelmSource,
-					Key:    validAthenaConf.Key(),
-					Active: true,
-					Valid:  true,
-					Config: validAthenaConf,
+					Source:     HelmSource,
+					Key:        validAthenaConf.Key(),
+					Active:     true,
+					Valid:      true,
+					ConfigType: AthenaConfigType,
+					Config:     validAthenaConf,
 				},
 			},
 		},
 		"Helm Source Update Config Invalid": {
 			initialStatuses: []*Status{
 				{
-					Source: HelmSource,
-					Key:    validAthenaConf.Key(),
-					Active: true,
-					Valid:  true,
-					Config: validAthenaConf,
+					Source:     HelmSource,
+					Key:        validAthenaConf.Key(),
+					Active:     true,
+					Valid:      true,
+					ConfigType: AthenaConfigType,
+					Config:     validAthenaConf,
 				},
 			},
 			configWatchers: map[ConfigSource]cloudconfig.KeyedConfigWatcher{
@@ -148,22 +157,24 @@ func TestIntegrationController_pullWatchers(t *testing.T) {
 			},
 			expectedStatuses: []*Status{
 				{
-					Source: HelmSource,
-					Key:    invalidAthenaConf.Key(),
-					Active: false,
-					Valid:  false,
-					Config: invalidAthenaConf,
+					Source:     HelmSource,
+					Key:        invalidAthenaConf.Key(),
+					Active:     false,
+					Valid:      false,
+					ConfigType: AthenaConfigType,
+					Config:     invalidAthenaConf,
 				},
 			},
 		},
 		"Helm Source New Config": {
 			initialStatuses: []*Status{
 				{
-					Source: HelmSource,
-					Key:    validAthenaConf.Key(),
-					Active: true,
-					Valid:  true,
-					Config: validAthenaConf,
+					Source:     HelmSource,
+					Key:        validAthenaConf.Key(),
+					Active:     true,
+					Valid:      true,
+					ConfigType: AthenaConfigType,
+					Config:     validAthenaConf,
 				},
 			},
 			configWatchers: map[ConfigSource]cloudconfig.KeyedConfigWatcher{
@@ -175,18 +186,12 @@ func TestIntegrationController_pullWatchers(t *testing.T) {
 			},
 			expectedStatuses: []*Status{
 				{
-					Source: HelmSource,
-					Key:    validAthenaConf.Key(),
-					Active: false, // this value changed
-					Valid:  true,
-					Config: validAthenaConf,
-				},
-				{
-					Source: HelmSource,
-					Key:    validBigQueryConf.Key(),
-					Active: true,
-					Valid:  true,
-					Config: validBigQueryConf,
+					Source:     HelmSource,
+					Key:        validBigQueryConf.Key(),
+					Active:     true,
+					Valid:      true,
+					ConfigType: AthenaConfigType,
+					Config:     validBigQueryConf,
 				},
 			},
 		},
@@ -202,22 +207,24 @@ func TestIntegrationController_pullWatchers(t *testing.T) {
 			},
 			expectedStatuses: []*Status{
 				{
-					Source: ConfigFileSource,
-					Key:    validAthenaConf.Key(),
-					Active: true,
-					Valid:  true,
-					Config: validAthenaConf,
+					Source:     ConfigFileSource,
+					Key:        validAthenaConf.Key(),
+					Active:     true,
+					Valid:      true,
+					ConfigType: AthenaConfigType,
+					Config:     validAthenaConf,
 				},
 			},
 		},
 		"Config File No Change": {
 			initialStatuses: []*Status{
 				{
-					Source: ConfigFileSource,
-					Key:    validAthenaConf.Key(),
-					Active: true,
-					Valid:  true,
-					Config: validAthenaConf,
+					Source:     ConfigFileSource,
+					Key:        validAthenaConf.Key(),
+					Active:     true,
+					Valid:      true,
+					ConfigType: AthenaConfigType,
+					Config:     validAthenaConf,
 				},
 			},
 			configWatchers: map[ConfigSource]cloudconfig.KeyedConfigWatcher{
@@ -229,22 +236,24 @@ func TestIntegrationController_pullWatchers(t *testing.T) {
 			},
 			expectedStatuses: []*Status{
 				{
-					Source: ConfigFileSource,
-					Key:    validAthenaConf.Key(),
-					Active: true,
-					Valid:  true,
-					Config: validAthenaConf,
+					Source:     ConfigFileSource,
+					Key:        validAthenaConf.Key(),
+					Active:     true,
+					Valid:      true,
+					ConfigType: AthenaConfigType,
+					Config:     validAthenaConf,
 				},
 			},
 		},
 		"Config File Update Config": {
 			initialStatuses: []*Status{
 				{
-					Source: ConfigFileSource,
-					Key:    validAthenaConf.Key(),
-					Active: true,
-					Valid:  true,
-					Config: validAthenaConf,
+					Source:     ConfigFileSource,
+					Key:        validAthenaConf.Key(),
+					Active:     true,
+					Valid:      true,
+					ConfigType: AthenaConfigType,
+					Config:     validAthenaConf,
 				},
 			},
 			configWatchers: map[ConfigSource]cloudconfig.KeyedConfigWatcher{
@@ -256,22 +265,24 @@ func TestIntegrationController_pullWatchers(t *testing.T) {
 			},
 			expectedStatuses: []*Status{
 				{
-					Source: ConfigFileSource,
-					Key:    validAthenaConf.Key(),
-					Active: true,
-					Valid:  true,
-					Config: validAthenaConf,
+					Source:     ConfigFileSource,
+					Key:        validAthenaConf.Key(),
+					Active:     true,
+					Valid:      true,
+					ConfigType: AthenaConfigType,
+					Config:     validAthenaConf,
 				},
 			},
 		},
 		"Config File Update Config Invalid": {
 			initialStatuses: []*Status{
 				{
-					Source: ConfigFileSource,
-					Key:    validAthenaConf.Key(),
-					Active: true,
-					Valid:  true,
-					Config: validAthenaConf,
+					Source:     ConfigFileSource,
+					Key:        validAthenaConf.Key(),
+					Active:     true,
+					Valid:      true,
+					ConfigType: AthenaConfigType,
+					Config:     validAthenaConf,
 				},
 			},
 			configWatchers: map[ConfigSource]cloudconfig.KeyedConfigWatcher{
@@ -283,22 +294,24 @@ func TestIntegrationController_pullWatchers(t *testing.T) {
 			},
 			expectedStatuses: []*Status{
 				{
-					Source: ConfigFileSource,
-					Key:    invalidAthenaConf.Key(),
-					Active: false,
-					Valid:  false,
-					Config: invalidAthenaConf,
+					Source:     ConfigFileSource,
+					Key:        invalidAthenaConf.Key(),
+					Active:     false,
+					Valid:      false,
+					ConfigType: AthenaConfigType,
+					Config:     invalidAthenaConf,
 				},
 			},
 		},
 		"Config File New Config": {
 			initialStatuses: []*Status{
 				{
-					Source: ConfigFileSource,
-					Key:    validAthenaConf.Key(),
-					Active: true,
-					Valid:  true,
-					Config: validAthenaConf,
+					Source:     ConfigFileSource,
+					Key:        validAthenaConf.Key(),
+					Active:     true,
+					Valid:      true,
+					ConfigType: AthenaConfigType,
+					Config:     validAthenaConf,
 				},
 			},
 			configWatchers: map[ConfigSource]cloudconfig.KeyedConfigWatcher{
@@ -310,18 +323,12 @@ func TestIntegrationController_pullWatchers(t *testing.T) {
 			},
 			expectedStatuses: []*Status{
 				{
-					Source: ConfigFileSource,
-					Key:    validAthenaConf.Key(),
-					Active: false, // this value changed
-					Valid:  true,
-					Config: validAthenaConf,
-				},
-				{
-					Source: ConfigFileSource,
-					Key:    validBigQueryConf.Key(),
-					Active: true,
-					Valid:  true,
-					Config: validBigQueryConf,
+					Source:     ConfigFileSource,
+					Key:        validBigQueryConf.Key(),
+					Active:     true,
+					Valid:      true,
+					ConfigType: BigQueryConfigType,
+					Config:     validBigQueryConf,
 				},
 			},
 		},
@@ -337,22 +344,24 @@ func TestIntegrationController_pullWatchers(t *testing.T) {
 			},
 			expectedStatuses: []*Status{
 				{
-					Source: MultiCloudSource,
-					Key:    validAthenaConf.Key(),
-					Active: true,
-					Valid:  true,
-					Config: validAthenaConf,
+					Source:     MultiCloudSource,
+					Key:        validAthenaConf.Key(),
+					Active:     true,
+					Valid:      true,
+					ConfigType: AthenaConfigType,
+					Config:     validAthenaConf,
 				},
 			},
 		},
 		"Multi Cloud No Change": {
 			initialStatuses: []*Status{
 				{
-					Source: MultiCloudSource,
-					Key:    validAthenaConf.Key(),
-					Active: true,
-					Valid:  true,
-					Config: validAthenaConf,
+					Source:     MultiCloudSource,
+					Key:        validAthenaConf.Key(),
+					Active:     true,
+					Valid:      true,
+					ConfigType: AthenaConfigType,
+					Config:     validAthenaConf,
 				},
 			},
 			configWatchers: map[ConfigSource]cloudconfig.KeyedConfigWatcher{
@@ -364,22 +373,24 @@ func TestIntegrationController_pullWatchers(t *testing.T) {
 			},
 			expectedStatuses: []*Status{
 				{
-					Source: MultiCloudSource,
-					Key:    validAthenaConf.Key(),
-					Active: true,
-					Valid:  true,
-					Config: validAthenaConf,
+					Source:     MultiCloudSource,
+					Key:        validAthenaConf.Key(),
+					Active:     true,
+					Valid:      true,
+					ConfigType: AthenaConfigType,
+					Config:     validAthenaConf,
 				},
 			},
 		},
 		"Multi Cloud Update Config": {
 			initialStatuses: []*Status{
 				{
-					Source: MultiCloudSource,
-					Key:    validAthenaConf.Key(),
-					Active: true,
-					Valid:  true,
-					Config: validAthenaConf,
+					Source:     MultiCloudSource,
+					Key:        validAthenaConf.Key(),
+					Active:     true,
+					Valid:      true,
+					ConfigType: AthenaConfigType,
+					Config:     validAthenaConf,
 				},
 			},
 			configWatchers: map[ConfigSource]cloudconfig.KeyedConfigWatcher{
@@ -391,22 +402,24 @@ func TestIntegrationController_pullWatchers(t *testing.T) {
 			},
 			expectedStatuses: []*Status{
 				{
-					Source: MultiCloudSource,
-					Key:    validAthenaConfModifiedProperty.Key(),
-					Active: true,
-					Valid:  true,
-					Config: validAthenaConfModifiedProperty,
+					Source:     MultiCloudSource,
+					Key:        validAthenaConfModifiedProperty.Key(),
+					Active:     true,
+					Valid:      true,
+					ConfigType: AthenaConfigType,
+					Config:     validAthenaConfModifiedProperty,
 				},
 			},
 		},
 		"Multi Cloud Update Config Invalid": {
 			initialStatuses: []*Status{
 				{
-					Source: MultiCloudSource,
-					Key:    validAthenaConf.Key(),
-					Active: true,
-					Valid:  true,
-					Config: validAthenaConf,
+					Source:     MultiCloudSource,
+					Key:        validAthenaConf.Key(),
+					Active:     true,
+					Valid:      true,
+					ConfigType: AthenaConfigType,
+					Config:     validAthenaConf,
 				},
 			},
 			configWatchers: map[ConfigSource]cloudconfig.KeyedConfigWatcher{
@@ -418,22 +431,24 @@ func TestIntegrationController_pullWatchers(t *testing.T) {
 			},
 			expectedStatuses: []*Status{
 				{
-					Source: MultiCloudSource,
-					Key:    invalidAthenaConf.Key(),
-					Active: false,
-					Valid:  false,
-					Config: invalidAthenaConf,
+					Source:     MultiCloudSource,
+					Key:        invalidAthenaConf.Key(),
+					Active:     false,
+					Valid:      false,
+					ConfigType: AthenaConfigType,
+					Config:     invalidAthenaConf,
 				},
 			},
 		},
 		"Multi Cloud New Config": {
 			initialStatuses: []*Status{
 				{
-					Source: MultiCloudSource,
-					Key:    validAthenaConf.Key(),
-					Active: true,
-					Valid:  true,
-					Config: validAthenaConf,
+					Source:     MultiCloudSource,
+					Key:        validAthenaConf.Key(),
+					Active:     true,
+					Valid:      true,
+					ConfigType: AthenaConfigType,
+					Config:     validAthenaConf,
 				},
 			},
 			configWatchers: map[ConfigSource]cloudconfig.KeyedConfigWatcher{
@@ -445,30 +460,41 @@ func TestIntegrationController_pullWatchers(t *testing.T) {
 			},
 			expectedStatuses: []*Status{
 				{
-					Source: MultiCloudSource,
-					Key:    validAthenaConf.Key(),
-					Active: true,
-					Valid:  true,
-					Config: validAthenaConf,
+					Source:     MultiCloudSource,
+					Key:        validBigQueryConf.Key(),
+					Active:     true,
+					Valid:      true,
+					ConfigType: BigQueryConfigType,
+					Config:     validBigQueryConf,
 				},
+			},
+		},
+		"Multi Cloud Delete All": {
+			initialStatuses: []*Status{
 				{
-					Source: MultiCloudSource,
-					Key:    validBigQueryConf.Key(),
-					Active: true,
-					Valid:  true,
-					Config: validBigQueryConf,
+					Source:     MultiCloudSource,
+					Key:        validAthenaConf.Key(),
+					Active:     true,
+					Valid:      true,
+					ConfigType: AthenaConfigType,
+					Config:     validAthenaConf,
 				},
 			},
+			configWatchers: map[ConfigSource]cloudconfig.KeyedConfigWatcher{
+				MultiCloudSource: &MockKeyedConfigWatcher{},
+			},
+			expectedStatuses: []*Status{},
 		},
 		// Watch Interaction
 		"New Helm, Existing Config File": {
 			initialStatuses: []*Status{
 				{
-					Source: ConfigFileSource,
-					Key:    validAthenaConf.Key(),
-					Active: true,
-					Valid:  true,
-					Config: validAthenaConf,
+					Source:     ConfigFileSource,
+					Key:        validAthenaConf.Key(),
+					Active:     true,
+					Valid:      true,
+					ConfigType: AthenaConfigType,
+					Config:     validAthenaConf,
 				},
 			},
 			configWatchers: map[ConfigSource]cloudconfig.KeyedConfigWatcher{
@@ -485,36 +511,40 @@ func TestIntegrationController_pullWatchers(t *testing.T) {
 			},
 			expectedStatuses: []*Status{
 				{
-					Source: ConfigFileSource,
-					Key:    validAthenaConf.Key(),
-					Active: false,
-					Valid:  true,
-					Config: validAthenaConf,
+					Source:     ConfigFileSource,
+					Key:        validAthenaConf.Key(),
+					Active:     false,
+					Valid:      true,
+					ConfigType: AthenaConfigType,
+					Config:     validAthenaConf,
 				},
 				{
-					Source: HelmSource,
-					Key:    validBigQueryConf.Key(),
-					Active: true,
-					Valid:  true,
-					Config: validBigQueryConf,
+					Source:     HelmSource,
+					Key:        validBigQueryConf.Key(),
+					Active:     true,
+					Valid:      true,
+					ConfigType: BigQueryConfigType,
+					Config:     validBigQueryConf,
 				},
 			},
 		},
 		"Update Helm, Existing Config File": {
 			initialStatuses: []*Status{
 				{
-					Source: HelmSource,
-					Key:    validAthenaConf.Key(),
-					Active: false,
-					Valid:  true,
-					Config: validAthenaConf,
+					Source:     HelmSource,
+					Key:        validAthenaConf.Key(),
+					Active:     false,
+					Valid:      true,
+					ConfigType: AthenaConfigType,
+					Config:     validAthenaConf,
 				},
 				{
-					Source: ConfigFileSource,
-					Key:    validBigQueryConf.Key(),
-					Active: true,
-					Valid:  true,
-					Config: validBigQueryConf,
+					Source:     ConfigFileSource,
+					Key:        validBigQueryConf.Key(),
+					Active:     true,
+					Valid:      true,
+					ConfigType: BigQueryConfigType,
+					Config:     validBigQueryConf,
 				},
 			},
 			configWatchers: map[ConfigSource]cloudconfig.KeyedConfigWatcher{
@@ -531,29 +561,32 @@ func TestIntegrationController_pullWatchers(t *testing.T) {
 			},
 			expectedStatuses: []*Status{
 				{
-					Source: HelmSource,
-					Key:    validAthenaConfModifiedProperty.Key(),
-					Active: true,
-					Valid:  true,
-					Config: validAthenaConfModifiedProperty,
+					Source:     HelmSource,
+					Key:        validAthenaConfModifiedProperty.Key(),
+					Active:     true,
+					Valid:      true,
+					ConfigType: AthenaConfigType,
+					Config:     validAthenaConfModifiedProperty,
 				},
 				{
-					Source: ConfigFileSource,
-					Key:    validBigQueryConf.Key(),
-					Active: false,
-					Valid:  true,
-					Config: validBigQueryConf,
+					Source:     ConfigFileSource,
+					Key:        validBigQueryConf.Key(),
+					Active:     false,
+					Valid:      true,
+					ConfigType: BigQueryConfigType,
+					Config:     validBigQueryConf,
 				},
 			},
 		},
 		"New Helm Invalid, Existing Config File": {
 			initialStatuses: []*Status{
 				{
-					Source: ConfigFileSource,
-					Key:    validBigQueryConf.Key(),
-					Active: true,
-					Valid:  true,
-					Config: validBigQueryConf,
+					Source:     ConfigFileSource,
+					Key:        validBigQueryConf.Key(),
+					Active:     true,
+					Valid:      true,
+					ConfigType: BigQueryConfigType,
+					Config:     validBigQueryConf,
 				},
 			},
 			configWatchers: map[ConfigSource]cloudconfig.KeyedConfigWatcher{
@@ -570,36 +603,40 @@ func TestIntegrationController_pullWatchers(t *testing.T) {
 			},
 			expectedStatuses: []*Status{
 				{
-					Source: ConfigFileSource,
-					Key:    validBigQueryConf.Key(),
-					Active: true,
-					Valid:  true,
-					Config: validBigQueryConf,
+					Source:     ConfigFileSource,
+					Key:        validBigQueryConf.Key(),
+					Active:     true,
+					Valid:      true,
+					ConfigType: BigQueryConfigType,
+					Config:     validBigQueryConf,
 				},
 				{
-					Source: HelmSource,
-					Key:    invalidAthenaConf.Key(),
-					Active: false,
-					Valid:  false,
-					Config: invalidAthenaConf,
+					Source:     HelmSource,
+					Key:        invalidAthenaConf.Key(),
+					Active:     false,
+					Valid:      false,
+					ConfigType: AthenaConfigType,
+					Config:     invalidAthenaConf,
 				},
 			},
 		},
 		"Update Helm Invalid, Existing Config File": {
 			initialStatuses: []*Status{
 				{
-					Source: ConfigFileSource,
-					Key:    validBigQueryConf.Key(),
-					Active: true,
-					Valid:  true,
-					Config: validBigQueryConf,
+					Source:     ConfigFileSource,
+					Key:        validBigQueryConf.Key(),
+					Active:     true,
+					Valid:      true,
+					ConfigType: BigQueryConfigType,
+					Config:     validBigQueryConf,
 				},
 				{
-					Source: HelmSource,
-					Key:    validAthenaConf.Key(),
-					Active: false,
-					Valid:  true,
-					Config: validAthenaConf,
+					Source:     HelmSource,
+					Key:        validAthenaConf.Key(),
+					Active:     false,
+					Valid:      true,
+					ConfigType: AthenaConfigType,
+					Config:     validAthenaConf,
 				},
 			},
 			configWatchers: map[ConfigSource]cloudconfig.KeyedConfigWatcher{
@@ -616,29 +653,32 @@ func TestIntegrationController_pullWatchers(t *testing.T) {
 			},
 			expectedStatuses: []*Status{
 				{
-					Source: ConfigFileSource,
-					Key:    validBigQueryConf.Key(),
-					Active: true,
-					Valid:  true,
-					Config: validBigQueryConf,
+					Source:     ConfigFileSource,
+					Key:        validBigQueryConf.Key(),
+					Active:     true,
+					Valid:      true,
+					ConfigType: BigQueryConfigType,
+					Config:     validBigQueryConf,
 				},
 				{
-					Source: HelmSource,
-					Key:    invalidAthenaConf.Key(),
-					Active: false,
-					Valid:  false,
-					Config: invalidAthenaConf,
+					Source:     HelmSource,
+					Key:        invalidAthenaConf.Key(),
+					Active:     false,
+					Valid:      false,
+					ConfigType: AthenaConfigType,
+					Config:     invalidAthenaConf,
 				},
 			},
 		},
 		"New Config File, Existing Helm": {
 			initialStatuses: []*Status{
 				{
-					Source: HelmSource,
-					Key:    validAthenaConf.Key(),
-					Active: true,
-					Valid:  true,
-					Config: validAthenaConf,
+					Source:     HelmSource,
+					Key:        validAthenaConf.Key(),
+					Active:     true,
+					Valid:      true,
+					ConfigType: AthenaConfigType,
+					Config:     validAthenaConf,
 				},
 			},
 			configWatchers: map[ConfigSource]cloudconfig.KeyedConfigWatcher{
@@ -655,36 +695,32 @@ func TestIntegrationController_pullWatchers(t *testing.T) {
 			},
 			expectedStatuses: []*Status{
 				{
-					Source: HelmSource,
-					Key:    validAthenaConf.Key(),
-					Active: false,
-					Valid:  true,
-					Config: validAthenaConf,
+					Source:     HelmSource,
+					Key:        validAthenaConf.Key(),
+					Active:     false,
+					Valid:      true,
+					ConfigType: AthenaConfigType,
+					Config:     validAthenaConf,
 				},
 				{
-					Source: ConfigFileSource,
-					Key:    validBigQueryConf.Key(),
-					Active: true,
-					Valid:  true,
-					Config: validBigQueryConf,
+					Source:     ConfigFileSource,
+					Key:        validBigQueryConf.Key(),
+					Active:     true,
+					Valid:      true,
+					ConfigType: BigQueryConfigType,
+					Config:     validBigQueryConf,
 				},
 			},
 		},
 		"Update Config File, Existing Helm": {
 			initialStatuses: []*Status{
 				{
-					Source: ConfigFileSource,
-					Key:    validAthenaConf.Key(),
-					Active: false,
-					Valid:  true,
-					Config: validAthenaConf,
-				},
-				{
-					Source: HelmSource,
-					Key:    validBigQueryConf.Key(),
-					Active: true,
-					Valid:  true,
-					Config: validBigQueryConf,
+					Source:     HelmSource,
+					Key:        validBigQueryConf.Key(),
+					Active:     true,
+					Valid:      true,
+					ConfigType: BigQueryConfigType,
+					Config:     validBigQueryConf,
 				},
 			},
 			configWatchers: map[ConfigSource]cloudconfig.KeyedConfigWatcher{
@@ -699,29 +735,24 @@ func TestIntegrationController_pullWatchers(t *testing.T) {
 			},
 			expectedStatuses: []*Status{
 				{
-					Source: ConfigFileSource,
-					Key:    validAthenaConfModifiedProperty.Key(),
-					Active: true,
-					Valid:  true,
-					Config: validAthenaConfModifiedProperty,
-				},
-				{
-					Source: HelmSource,
-					Key:    validBigQueryConf.Key(),
-					Active: false,
-					Valid:  true,
-					Config: validBigQueryConf,
+					Source:     ConfigFileSource,
+					Key:        validAthenaConfModifiedProperty.Key(),
+					Active:     true,
+					Valid:      true,
+					ConfigType: AthenaConfigType,
+					Config:     validAthenaConfModifiedProperty,
 				},
 			},
 		},
 		"New Config File Invalid, Existing Helm": {
 			initialStatuses: []*Status{
 				{
-					Source: HelmSource,
-					Key:    validBigQueryConf.Key(),
-					Active: true,
-					Valid:  true,
-					Config: validBigQueryConf,
+					Source:     HelmSource,
+					Key:        validBigQueryConf.Key(),
+					Active:     true,
+					Valid:      true,
+					ConfigType: BigQueryConfigType,
+					Config:     validBigQueryConf,
 				},
 			},
 			configWatchers: map[ConfigSource]cloudconfig.KeyedConfigWatcher{
@@ -738,36 +769,40 @@ func TestIntegrationController_pullWatchers(t *testing.T) {
 			},
 			expectedStatuses: []*Status{
 				{
-					Source: HelmSource,
-					Key:    validBigQueryConf.Key(),
-					Active: true,
-					Valid:  true,
-					Config: validBigQueryConf,
+					Source:     HelmSource,
+					Key:        validBigQueryConf.Key(),
+					Active:     true,
+					Valid:      true,
+					ConfigType: BigQueryConfigType,
+					Config:     validBigQueryConf,
 				},
 				{
-					Source: ConfigFileSource,
-					Key:    invalidAthenaConf.Key(),
-					Active: false,
-					Valid:  false,
-					Config: invalidAthenaConf,
+					Source:     ConfigFileSource,
+					Key:        invalidAthenaConf.Key(),
+					Active:     false,
+					Valid:      false,
+					ConfigType: AthenaConfigType,
+					Config:     invalidAthenaConf,
 				},
 			},
 		},
 		"Update Config File Invalid, Existing Helm": {
 			initialStatuses: []*Status{
 				{
-					Source: HelmSource,
-					Key:    validBigQueryConf.Key(),
-					Active: true,
-					Valid:  true,
-					Config: validBigQueryConf,
+					Source:     HelmSource,
+					Key:        validBigQueryConf.Key(),
+					Active:     true,
+					Valid:      true,
+					ConfigType: BigQueryConfigType,
+					Config:     validBigQueryConf,
 				},
 				{
-					Source: ConfigFileSource,
-					Key:    validAthenaConf.Key(),
-					Active: false,
-					Valid:  true,
-					Config: validAthenaConf,
+					Source:     ConfigFileSource,
+					Key:        validAthenaConf.Key(),
+					Active:     false,
+					Valid:      true,
+					ConfigType: AthenaConfigType,
+					Config:     validAthenaConf,
 				},
 			},
 			configWatchers: map[ConfigSource]cloudconfig.KeyedConfigWatcher{
@@ -784,18 +819,20 @@ func TestIntegrationController_pullWatchers(t *testing.T) {
 			},
 			expectedStatuses: []*Status{
 				{
-					Source: HelmSource,
-					Key:    validBigQueryConf.Key(),
-					Active: true,
-					Valid:  true,
-					Config: validBigQueryConf,
+					Source:     HelmSource,
+					Key:        validBigQueryConf.Key(),
+					Active:     true,
+					Valid:      true,
+					ConfigType: BigQueryConfigType,
+					Config:     validBigQueryConf,
 				},
 				{
-					Source: ConfigFileSource,
-					Key:    invalidAthenaConf.Key(),
-					Active: false,
-					Valid:  false,
-					Config: invalidAthenaConf,
+					Source:     ConfigFileSource,
+					Key:        invalidAthenaConf.Key(),
+					Active:     false,
+					Valid:      false,
+					ConfigType: AthenaConfigType,
+					Config:     invalidAthenaConf,
 				},
 			},
 		},
@@ -804,68 +841,549 @@ func TestIntegrationController_pullWatchers(t *testing.T) {
 	for name, tc := range testCases {
 		t.Run(name, func(t *testing.T) {
 			// Test set up and validation
-			initialStatuses := make(map[configID]*Status)
-			for _, status := range tc.initialStatuses {
-				iID := configID{
-					source: status.Source,
-					key:    status.Key,
-				}
-				if _, ok := initialStatuses[iID]; ok {
-					t.Errorf("invalid test, duplicate initial status with key: %s source: %s", iID.key, iID.source.String())
-				}
-				initialStatuses[iID] = status
-			}
-
-			expectedStatuses := make(map[configID]*Status)
-			for _, status := range tc.expectedStatuses {
-				iID := configID{
-					source: status.Source,
-					key:    status.Key,
-				}
-				if _, ok := expectedStatuses[iID]; ok {
-					t.Errorf("invalid test, duplicate expected status with key: %s source: %s", iID.key, iID.source.String())
-				}
-				expectedStatuses[iID] = status
+			initialStatuses, err := buildStatuses(tc.initialStatuses)
+			if err != nil {
+				t.Errorf("initial statuses: %s", err.Error())
+			}
+
+			expectedStatuses, err := buildStatuses(tc.expectedStatuses)
+			if err != nil {
+				t.Errorf("initial statuses: %s", err.Error())
 			}
 
+			tempDir := os.TempDir()
+			path := filepath.Join(tempDir, configFile)
+			defer os.Remove(path)
+
 			// Initialize controller
 			icd := &Controller{
-				statuses: initialStatuses,
+				path:     path,
 				watchers: tc.configWatchers,
 			}
+			err = icd.save(initialStatuses)
+			if err != nil {
+				t.Errorf("failed to save initial statuses: %s", err.Error())
+			}
+
+			// Functionality being tested
 			icd.pullWatchers()
-			if len(icd.statuses) != len(tc.expectedStatuses) {
-				t.Errorf("integration statueses did not have the correct length actaul: %d, expected: %d", len(icd.statuses), len(tc.expectedStatuses))
+
+			// Test Result
+			status, err := icd.load()
+			if err != nil {
+				t.Errorf("failed to load status file: %s", err.Error())
+			}
+
+			err = checkStatuses(status, expectedStatuses)
+			if err != nil {
+				t.Errorf("statuses equality check failed: %s", err.Error())
+			}
+		})
+	}
+}
+
+func TestIntegrationController_CreateConfig(t *testing.T) {
+	testCases := map[string]struct {
+		initial   []*Status
+		expected  []*Status
+		input     cloudconfig.KeyedConfig
+		expectErr bool
+	}{
+		"Invalid Config": {
+			initial:   nil,
+			expected:  nil,
+			input:     invalidAthenaConf,
+			expectErr: true,
+		},
+		"config exists from this source": {
+			initial: []*Status{
+				makeStatus(validAthenaConf, true, ConfigControllerSource),
+			},
+			expected: []*Status{
+				makeStatus(validAthenaConf, true, ConfigControllerSource),
+			},
+			input:     validAthenaConf,
+			expectErr: true,
+		},
+		"config exists from this source altered": {
+			initial: []*Status{
+				makeStatus(validAthenaConf, true, ConfigControllerSource),
+			},
+			expected: []*Status{
+				makeStatus(validAthenaConf, true, ConfigControllerSource),
+			},
+			input:     validAthenaConfModifiedProperty,
+			expectErr: true,
+		},
+		"config exists from other source enabled": {
+			initial: []*Status{
+				makeStatus(validAthenaConf, true, MultiCloudSource),
+			},
+			expected: []*Status{
+				makeStatus(validAthenaConf, false, MultiCloudSource),
+				makeStatus(validAthenaConf, true, ConfigControllerSource),
+			},
+			input:     validAthenaConf,
+			expectErr: false,
+		},
+		"config exists from other source disabled": {
+			initial: []*Status{
+				makeStatus(validAthenaConf, false, MultiCloudSource),
+			},
+			expected: []*Status{
+				makeStatus(validAthenaConf, false, MultiCloudSource),
+				makeStatus(validAthenaConf, true, ConfigControllerSource),
+			},
+			input:     validAthenaConf,
+			expectErr: false,
+		},
+		"config into empty": {
+			initial: []*Status{},
+			expected: []*Status{
+				makeStatus(validAthenaConf, true, ConfigControllerSource),
+			},
+			input:     validAthenaConf,
+			expectErr: false,
+		},
+	}
+
+	for name, tc := range testCases {
+		t.Run(name, func(t *testing.T) {
+			// Test set up and validation
+			initialStatuses, err := buildStatuses(tc.initial)
+			if err != nil {
+				t.Errorf("initial statuses: %s", err.Error())
+			}
+
+			expectedStatuses, err := buildStatuses(tc.expected)
+			if err != nil {
+				t.Errorf("initial statuses: %s", err.Error())
+			}
+
+			tempDir := os.TempDir()
+			path := filepath.Join(tempDir, configFile)
+			defer os.Remove(path)
+
+			// Initialize controller
+			icd := &Controller{
+				path: path,
+			}
+			err = icd.save(initialStatuses)
+			if err != nil {
+				t.Errorf("failed to save initial statuses: %s", err.Error())
+			}
+
+			// Functionality being tested
+			err = icd.CreateConfig(tc.input)
+
+			// Test Result
+			if err != nil && !tc.expectErr {
+				t.Errorf("unexpected error when creating config: %s", err.Error())
+			}
+			if err == nil && tc.expectErr {
+				t.Errorf("no error where expect")
+			}
+
+			status, err := icd.load()
+			if err != nil {
+				t.Errorf("failed to load status file: %s", err.Error())
+			}
+
+			err = checkStatuses(status, expectedStatuses)
+			if err != nil {
+				t.Errorf("statuses equality check failed: %s", err.Error())
+			}
+		})
+	}
+
+}
+
+func TestIntegrationController_EnableConfig(t *testing.T) {
+	testCases := map[string]struct {
+		initial     []*Status
+		expected    []*Status
+		inputKey    string
+		inputSource string
+		expectErr   bool
+	}{
+		"config doesn't exist": {
+			initial:     []*Status{},
+			expected:    []*Status{},
+			inputKey:    validAthenaConf.Key(),
+			inputSource: ConfigControllerSource.String(),
+			expectErr:   true,
+		},
+		"config is already enabled": {
+			initial: []*Status{
+				makeStatus(validAthenaConf, true, ConfigControllerSource),
+			},
+			expected: []*Status{
+				makeStatus(validAthenaConf, true, ConfigControllerSource),
+			},
+			inputKey:    validAthenaConf.Key(),
+			inputSource: ConfigControllerSource.String(),
+			expectErr:   true,
+		},
+		"alternate source": {
+			initial: []*Status{
+				makeStatus(validAthenaConf, false, MultiCloudSource),
+			},
+			expected: []*Status{
+				makeStatus(validAthenaConf, true, MultiCloudSource),
+			},
+			inputKey:    validAthenaConf.Key(),
+			inputSource: MultiCloudSource.String(),
+			expectErr:   false,
+		},
+		"enabled disabled single config": {
+			initial: []*Status{
+				makeStatus(validAthenaConf, false, ConfigControllerSource),
+			},
+			expected: []*Status{
+				makeStatus(validAthenaConf, true, ConfigControllerSource),
+			},
+			inputKey:    validAthenaConf.Key(),
+			inputSource: ConfigControllerSource.String(),
+			expectErr:   false,
+		},
+		"enable config which is enabled by another source": {
+			initial: []*Status{
+				makeStatus(validAthenaConf, false, ConfigControllerSource),
+				makeStatus(validAthenaConf, true, MultiCloudSource),
+			},
+			expected: []*Status{
+				makeStatus(validAthenaConf, true, ConfigControllerSource),
+				makeStatus(validAthenaConf, false, MultiCloudSource),
+			},
+			inputKey:    validAthenaConf.Key(),
+			inputSource: ConfigControllerSource.String(),
+			expectErr:   false,
+		},
+	}
+
+	for name, tc := range testCases {
+		t.Run(name, func(t *testing.T) {
+			// Test set up and validation
+			initialStatuses, err := buildStatuses(tc.initial)
+			if err != nil {
+				t.Errorf("initial statuses: %s", err.Error())
+			}
+
+			expectedStatuses, err := buildStatuses(tc.expected)
+			if err != nil {
+				t.Errorf("initial statuses: %s", err.Error())
+			}
+
+			tempDir := os.TempDir()
+			path := filepath.Join(tempDir, configFile)
+			defer os.Remove(path)
+
+			// Initialize controller
+			icd := &Controller{
+				path: path,
+			}
+			err = icd.save(initialStatuses)
+			if err != nil {
+				t.Errorf("failed to save initial statuses: %s", err.Error())
+			}
+
+			// Functionality being tested
+			err = icd.EnableConfig(tc.inputKey, tc.inputSource)
+
+			// Test Result
+			if err != nil && !tc.expectErr {
+				t.Errorf("unexpected error when enabling config: %s", err.Error())
+			}
+			if err == nil && tc.expectErr {
+				t.Errorf("no error where expect")
+			}
+
+			status, err := icd.load()
+			if err != nil {
+				t.Errorf("failed to load status file: %s", err.Error())
+			}
+
+			err = checkStatuses(status, expectedStatuses)
+			if err != nil {
+				t.Errorf("statuses equality check failed: %s", err.Error())
+			}
+		})
+	}
+}
+
+func TestIntegrationController_DisableConfig(t *testing.T) {
+	testCases := map[string]struct {
+		initial     []*Status
+		expected    []*Status
+		inputKey    string
+		inputSource string
+		expectErr   bool
+	}{
+		"config doesn't exist": {
+			initial:     []*Status{},
+			expected:    []*Status{},
+			inputKey:    validAthenaConf.Key(),
+			inputSource: ConfigControllerSource.String(),
+			expectErr:   true,
+		},
+
+		"config is already disabled": {
+			initial: []*Status{
+				makeStatus(validAthenaConf, false, ConfigControllerSource),
+				makeStatus(validAthenaConf, true, MultiCloudSource),
+			},
+			expected: []*Status{
+				makeStatus(validAthenaConf, false, ConfigControllerSource),
+				makeStatus(validAthenaConf, true, MultiCloudSource),
+			},
+			inputKey:    validAthenaConf.Key(),
+			inputSource: ConfigControllerSource.String(),
+			expectErr:   true,
+		},
+		"disable single config": {
+			initial: []*Status{
+				makeStatus(validAthenaConf, true, ConfigControllerSource),
+			},
+			expected: []*Status{
+				makeStatus(validAthenaConf, false, ConfigControllerSource),
+			},
+			inputKey:    validAthenaConf.Key(),
+			inputSource: ConfigControllerSource.String(),
+			expectErr:   false,
+		},
+		"alternate source": {
+			initial: []*Status{
+				makeStatus(validAthenaConf, true, MultiCloudSource),
+			},
+			expected: []*Status{
+				makeStatus(validAthenaConf, false, MultiCloudSource),
+			},
+			inputKey:    validAthenaConf.Key(),
+			inputSource: MultiCloudSource.String(),
+			expectErr:   false,
+		},
+		"disable config, matching config from separate source": {
+			initial: []*Status{
+				makeStatus(validAthenaConf, true, ConfigControllerSource),
+				makeStatus(validAthenaConf, false, MultiCloudSource),
+			},
+			expected: []*Status{
+				makeStatus(validAthenaConf, false, ConfigControllerSource),
+				makeStatus(validAthenaConf, false, MultiCloudSource),
+			},
+			inputKey:    validAthenaConf.Key(),
+			inputSource: ConfigControllerSource.String(),
+			expectErr:   false,
+		},
+	}
+
+	for name, tc := range testCases {
+		t.Run(name, func(t *testing.T) {
+			// Test set up and validation
+			initialStatuses, err := buildStatuses(tc.initial)
+			if err != nil {
+				t.Errorf("initial statuses: %s", err.Error())
+			}
+
+			expectedStatuses, err := buildStatuses(tc.expected)
+			if err != nil {
+				t.Errorf("initial statuses: %s", err.Error())
+			}
+
+			tempDir := os.TempDir()
+			path := filepath.Join(tempDir, configFile)
+			defer os.Remove(path)
+
+			// Initialize controller
+			icd := &Controller{
+				path: path,
+			}
+			err = icd.save(initialStatuses)
+			if err != nil {
+				t.Errorf("failed to save initial statuses: %s", err.Error())
+			}
+
+			// Functionality being tested
+			err = icd.DisableConfig(tc.inputKey, tc.inputSource)
+
+			// Test Result
+			if err != nil && !tc.expectErr {
+				t.Errorf("unexpected error when disabling config: %s", err.Error())
+			}
+			if err == nil && tc.expectErr {
+				t.Errorf("no error where expect")
+			}
+
+			status, err := icd.load()
+			if err != nil {
+				t.Errorf("failed to load status file: %s", err.Error())
+			}
+
+			err = checkStatuses(status, expectedStatuses)
+			if err != nil {
+				t.Errorf("statuses equality check failed: %s", err.Error())
 			}
+		})
+	}
+}
+
+func TestIntegrationController_DeleteConfig(t *testing.T) {
+	testCases := map[string]struct {
+		initial     []*Status
+		expected    []*Status
+		inputKey    string
+		inputSource string
+		expectErr   bool
+	}{
+		"config doesn't exist": {
+			initial:     []*Status{},
+			expected:    []*Status{},
+			inputKey:    validAthenaConf.Key(),
+			inputSource: ConfigControllerSource.String(),
+			expectErr:   true,
+		},
+		"invalid source": {
+			initial:     []*Status{},
+			expected:    []*Status{},
+			inputKey:    validAthenaConf.Key(),
+			inputSource: MultiCloudSource.String(),
+			expectErr:   true,
+		},
+		"delete single config": {
+			initial: []*Status{
+				makeStatus(validAthenaConf, true, ConfigControllerSource),
+			},
+			expected:    []*Status{},
+			inputKey:    validAthenaConf.Key(),
+			inputSource: ConfigControllerSource.String(),
+			expectErr:   false,
+		},
+		"disable config, matching config from separate source": {
+			initial: []*Status{
+				makeStatus(validAthenaConf, true, ConfigControllerSource),
+				makeStatus(validAthenaConf, false, MultiCloudSource),
+			},
+			expected: []*Status{
+				makeStatus(validAthenaConf, false, MultiCloudSource),
+			},
+			inputKey:    validAthenaConf.Key(),
+			inputSource: ConfigControllerSource.String(),
+			expectErr:   false,
+		},
+	}
 
-			for iID, actualStatus := range icd.statuses {
-				expectedStatus, ok := expectedStatuses[iID]
-				if !ok {
-					t.Errorf("expected integration statuses is missing with integration ID: %v", iID)
-				}
+	for name, tc := range testCases {
+		t.Run(name, func(t *testing.T) {
+			// Test set up and validation
+			initialStatuses, err := buildStatuses(tc.initial)
+			if err != nil {
+				t.Errorf("initial statuses: %s", err.Error())
+			}
 
-				// failure here indicates an issue with the configID
-				if actualStatus.Key != expectedStatus.Key {
-					t.Errorf("integration status does not have the correct Key values actual: %s, expected: %s", actualStatus.Key, expectedStatus.Key)
-				}
+			expectedStatuses, err := buildStatuses(tc.expected)
+			if err != nil {
+				t.Errorf("initial statuses: %s", err.Error())
+			}
 
-				// failure here indicates an issue with the configID
-				if actualStatus.Key != expectedStatus.Key {
-					t.Errorf("integration status does not have the correct Source values actual: %s, expected: %s", actualStatus.Source, expectedStatus.Source)
-				}
+			tempDir := os.TempDir()
+			path := filepath.Join(tempDir, configFile)
+			defer os.Remove(path)
 
-				if actualStatus.Active != expectedStatus.Active {
-					t.Errorf("integration status does not have the correct Active values actual: %v, expected: %v", actualStatus.Active, expectedStatus.Active)
-				}
+			// Initialize controller
+			icd := &Controller{
+				path: path,
+			}
+			err = icd.save(initialStatuses)
+			if err != nil {
+				t.Errorf("failed to save initial statuses: %s", err.Error())
+			}
 
-				if actualStatus.Valid != expectedStatus.Valid {
-					t.Errorf("integration status does not have the correct Valid values actual: %v, expected: %v", actualStatus.Valid, expectedStatus.Valid)
-				}
+			// Functionality being tested
+			err = icd.DeleteConfig(tc.inputKey, tc.inputSource)
 
-				if !actualStatus.Config.Equals(expectedStatus.Config) {
-					t.Errorf("integration status does not have the correct config values actual: %v, expected: %v", actualStatus.Config, expectedStatus.Config)
-				}
+			// Test Result
+			if err != nil && !tc.expectErr {
+				t.Errorf("unexpected error when deleting config: %s", err.Error())
+			}
+			if err == nil && tc.expectErr {
+				t.Errorf("no error where expect")
+			}
+
+			status, err := icd.load()
+			if err != nil {
+				t.Errorf("failed to load status file: %s", err.Error())
+			}
+
+			err = checkStatuses(status, expectedStatuses)
+			if err != nil {
+				t.Errorf("statuses equality check failed: %s", err.Error())
 			}
 		})
 	}
 }
+
+func makeStatus(config cloudconfig.KeyedConfig, active bool, source ConfigSource) *Status {
+	err := config.Validate()
+	valid := err == nil
+
+	configType, err := ConfigTypeFromConfig(config)
+	if err != nil {
+		panic(fmt.Errorf("config type not recognised: %w", err))
+	}
+
+	return &Status{
+		Source:     source,
+		Key:        config.Key(),
+		Active:     active,
+		Valid:      valid,
+		ConfigType: configType,
+		Config:     config,
+	}
+}
+
+func buildStatuses(statusList []*Status) (Statuses, error) {
+	statuses := Statuses{}
+	for _, status := range statusList {
+		if _, ok := statuses.Get(status.Key, status.Source); ok {
+			return nil, fmt.Errorf("invalid test, duplicate status with key: %s source: %s", status.Key, status.Source.String())
+		}
+		statuses.Insert(status)
+	}
+	return statuses, nil
+}
+
+func checkStatuses(actual, expected Statuses) error {
+	if len(actual.List()) != len(expected.List()) {
+		return fmt.Errorf("integration statueses did not have the correct length actaul: %d, expected: %d", len(actual.List()), len(expected.List()))
+	}
+
+	for _, actualStatus := range actual.List() {
+		expectedStatus, ok := expected.Get(actualStatus.Key, actualStatus.Source)
+		if !ok {
+			return fmt.Errorf("expected integration statuses is missing with integration key: %s, source: %s", actualStatus.Key, actualStatus.Source.String())
+		}
+
+		// failure here indicates an issue with the configID
+		if actualStatus.Key != expectedStatus.Key {
+			return fmt.Errorf("integration status does not have the correct Key values actual: %s, expected: %s", actualStatus.Key, expectedStatus.Key)
+		}
+
+		// failure here indicates an issue with the configID
+		if actualStatus.Key != expectedStatus.Key {
+			return fmt.Errorf("integration status does not have the correct Source values actual: %s, expected: %s", actualStatus.Source, expectedStatus.Source)
+		}
+
+		if actualStatus.Active != expectedStatus.Active {
+			return fmt.Errorf("integration status does not have the correct Active values actual: %v, expected: %v", actualStatus.Active, expectedStatus.Active)
+		}
+
+		if actualStatus.Valid != expectedStatus.Valid {
+			return fmt.Errorf("integration status does not have the correct Valid values actual: %v, expected: %v", actualStatus.Valid, expectedStatus.Valid)
+		}
+
+		if !actualStatus.Config.Equals(expectedStatus.Config) {
+			return fmt.Errorf("integration status does not have the correct config values actual: %v, expected: %v", actualStatus.Config, expectedStatus.Config)
+		}
+	}
+	return nil
+}

+ 145 - 0
pkg/cloud/config/statuses.go

@@ -0,0 +1,145 @@
+package config
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/opencost/opencost/core/pkg/util/json"
+	"github.com/opencost/opencost/pkg/cloud"
+	"github.com/opencost/opencost/pkg/cloud/aws"
+	"github.com/opencost/opencost/pkg/cloud/azure"
+	"github.com/opencost/opencost/pkg/cloud/gcp"
+)
+
+const (
+	S3ConfigType           = "s3"
+	AthenaConfigType       = "athena"
+	BigQueryConfigType     = "bigquery"
+	AzureStorageConfigType = "azurestorage"
+)
+
+func ConfigTypeFromConfig(config cloud.KeyedConfig) (string, error) {
+	switch config.(type) {
+	case *aws.S3Configuration:
+		return S3ConfigType, nil
+	case *aws.AthenaConfiguration:
+		return AthenaConfigType, nil
+	case *gcp.BigQueryConfiguration:
+		return BigQueryConfigType, nil
+	case *azure.StorageConfiguration:
+		return AzureStorageConfigType, nil
+	}
+	return "", fmt.Errorf("failed to config type for config with key: %s, type %T", config.Key(), config)
+}
+
+type Statuses map[ConfigSource]map[string]*Status
+
+func (s Statuses) Get(key string, source ConfigSource) (*Status, bool) {
+	if _, ok := s[source]; !ok {
+		return nil, false
+	}
+	status, ok := s[source][key]
+	return status, ok
+}
+
+func (s Statuses) Insert(status *Status) {
+	if _, ok := s[status.Source]; !ok {
+		s[status.Source] = map[string]*Status{}
+	}
+	s[status.Source][status.Key] = status
+}
+
+func (s Statuses) List() []*Status {
+	var list []*Status
+	for _, statusesByKey := range s {
+		for _, status := range statusesByKey {
+			list = append(list, status)
+		}
+	}
+	return list
+}
+
+type Status struct {
+	Source     ConfigSource      `json:"source"`
+	Key        string            `json:"key"`
+	Active     bool              `json:"active"`
+	Valid      bool              `json:"valid"`
+	ConfigType string            `json:"configType"`
+	Config     cloud.KeyedConfig `json:"config"`
+}
+
+func (s *Status) UnmarshalJSON(b []byte) error {
+	var f interface{}
+	err := json.Unmarshal(b, &f)
+	if err != nil {
+		return err
+	}
+
+	fmap := f.(map[string]interface{})
+
+	sourceFloat, err := cloud.GetInterfaceValue[float64](fmap, "source")
+	if err != nil {
+		return fmt.Errorf("Status: UnmarshalJSON: %s", err.Error())
+	}
+	source := ConfigSource(int(sourceFloat))
+
+	key, err := cloud.GetInterfaceValue[string](fmap, "key")
+	if err != nil {
+		return fmt.Errorf("Status: UnmarshalJSON: %s", err.Error())
+	}
+
+	active, err := cloud.GetInterfaceValue[bool](fmap, "active")
+	if err != nil {
+		return fmt.Errorf("Status: UnmarshalJSON: %s", err.Error())
+	}
+
+	valid, err := cloud.GetInterfaceValue[bool](fmap, "valid")
+	if err != nil {
+		return fmt.Errorf("Status: UnmarshalJSON: %s", err.Error())
+	}
+
+	configType, err := cloud.GetInterfaceValue[string](fmap, "configType")
+	if err != nil {
+		return fmt.Errorf("Status: UnmarshalJSON: %s", err.Error())
+	}
+
+	// Pick correct implementation to unmarshal into
+	var config cloud.KeyedConfig
+	switch strings.ToLower(configType) {
+	case S3ConfigType:
+		config = &aws.S3Configuration{}
+	case AthenaConfigType:
+		config = &aws.AthenaConfiguration{}
+	case BigQueryConfigType:
+		config = &gcp.BigQueryConfiguration{}
+	case AzureStorageConfigType:
+		config = &azure.StorageConfiguration{}
+	default:
+		return fmt.Errorf("Status: UnmarshalJSON: config type '%s' is not recognized", configType)
+	}
+
+	configAny, err := cloud.GetInterfaceValue[any](fmap, "config")
+	if err != nil {
+		return fmt.Errorf("Status: UnmarshalJSON: %s", err.Error())
+	}
+
+	// convert the interface back to a []Byte so that it can be unmarshalled into the correct type
+	fBin, err := json.Marshal(configAny)
+	if err != nil {
+		return fmt.Errorf("Status: UnmarshalJSON: could not marshal value %v: %w", f, err)
+	}
+
+	err = json.Unmarshal(fBin, config)
+	if err != nil {
+		return fmt.Errorf("Status: UnmarshalJSON: failed to unmarshal into Configuration type %T from value %v: %w", config, f, err)
+	}
+
+	// Set Values
+	s.Source = source
+	s.Key = key
+	s.Active = active
+	s.Valid = valid
+	s.ConfigType = configType
+	s.Config = config
+	return nil
+}

+ 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;