Browse Source

Update azure storage, add bucket storage tests, s3 storage fix

Signed-off-by: Sean Holcomb <seanholcomb@gmail.com>
Sean Holcomb 2 years ago
parent
commit
a61b8b51e5
5 changed files with 703 additions and 368 deletions
  1. 2 5
      go.mod
  2. 0 15
      go.sum
  3. 203 343
      pkg/storage/azurestorage.go
  4. 489 0
      pkg/storage/bucketstorage_test.go
  5. 9 5
      pkg/storage/s3storage.go

+ 2 - 5
go.mod

@@ -9,14 +9,11 @@ require (
 	cloud.google.com/go/bigquery v1.59.1
 	cloud.google.com/go/compute/metadata v0.2.3
 	cloud.google.com/go/storage v1.37.0
-	github.com/Azure/azure-pipeline-go v0.2.3
 	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
 	github.com/Azure/go-autorest/autorest/azure/auth v0.5.11
 	github.com/aliyun/alibaba-cloud-sdk-go v1.62.3
 	github.com/aws/aws-sdk-go v1.50.8
@@ -59,6 +56,7 @@ require (
 	golang.org/x/sync v0.6.0
 	golang.org/x/text v0.14.0
 	google.golang.org/api v0.162.0
+	google.golang.org/protobuf v1.32.0
 	gopkg.in/yaml.v2 v2.4.0
 	k8s.io/api v0.25.3
 	k8s.io/apimachinery v0.25.3
@@ -72,6 +70,7 @@ require (
 	cloud.google.com/go/iam v1.1.6 // 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/adal v0.9.21 // indirect
 	github.com/Azure/go-autorest/autorest/azure/cli v0.4.5 // indirect
 	github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect
 	github.com/Azure/go-autorest/autorest/to v0.4.0 // indirect
@@ -138,7 +137,6 @@ require (
 	github.com/magiconair/properties v1.8.5 // indirect
 	github.com/mailru/easyjson v0.7.7 // indirect
 	github.com/mattn/go-colorable v0.1.13 // indirect
-	github.com/mattn/go-ieproxy v0.0.1 // indirect
 	github.com/mattn/go-isatty v0.0.20 // indirect
 	github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
 	github.com/minio/md5-simd v1.1.2 // indirect
@@ -184,7 +182,6 @@ require (
 	google.golang.org/genproto/googleapis/api v0.0.0-20240205150955-31a09d347014 // indirect
 	google.golang.org/genproto/googleapis/rpc v0.0.0-20240221002015-b0ce06bbee7c // indirect
 	google.golang.org/grpc v1.62.0 // indirect
-	google.golang.org/protobuf v1.32.0 // indirect
 	gopkg.in/inf.v0 v0.9.1 // indirect
 	gopkg.in/ini.v1 v1.67.0 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect

+ 0 - 15
go.sum

@@ -53,8 +53,6 @@ cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9
 cloud.google.com/go/storage v1.37.0 h1:WI8CsaFO8Q9KjPVtsZ5Cmi0dXV25zMoX0FklT7c3Jm4=
 cloud.google.com/go/storage v1.37.0/go.mod h1:i34TiT2IhiNDmcj65PqwCjcoUX7Z5pLzS8DEmoiFq1k=
 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 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=
@@ -67,14 +65,11 @@ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.5.0
 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=
 github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24=
 github.com/Azure/go-autorest/autorest v0.11.24/go.mod h1:G6kyRlFnTuSbEYkQGawPfsCswgme4iYf6rfSKUDzbCc=
 github.com/Azure/go-autorest/autorest v0.11.28 h1:ndAExarwr5Y+GaHE6VCaY1kyS/HwwGGyuimVhWsHOEM=
 github.com/Azure/go-autorest/autorest v0.11.28/go.mod h1:MrkzG3Y3AH668QyF9KRk5neJnGgmhQ6krbhR8Q5eMvA=
-github.com/Azure/go-autorest/autorest/adal v0.9.13/go.mod h1:W/MM4U6nLxnIskrw4UwWzlHfGjwUS50aOsc/I3yuU8M=
 github.com/Azure/go-autorest/autorest/adal v0.9.18/go.mod h1:XVVeme+LZwABT8K5Lc3hA4nAe8LDBVle26gTrguhhPQ=
 github.com/Azure/go-autorest/autorest/adal v0.9.21 h1:jjQnVFXPfekaqb8vIsv2G1lxshoW+oGv4MDlhRtnYZk=
 github.com/Azure/go-autorest/autorest/adal v0.9.21/go.mod h1:zua7mBUaCc5YnSLKYgGJR/w5ePdMDA6H56upLsHzA9U=
@@ -206,7 +201,6 @@ github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
 github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
 github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
-github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k=
 github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
 github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
 github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
@@ -323,7 +317,6 @@ github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm4
 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.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=
@@ -405,7 +398,6 @@ github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZY
 github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
 github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
-github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
 github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
 github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
@@ -430,8 +422,6 @@ github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope
 github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
 github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
 github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
-github.com/mattn/go-ieproxy v0.0.1 h1:qiyop7gCflfhwCzGyeT0gro3sF9AIg9HU98JORTkqfI=
-github.com/mattn/go-ieproxy v0.0.1/go.mod h1:pYabZ6IHcRpFh7vIaLfK7rdcWgFEb3SFJ6/gNWuh88E=
 github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
 github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
 github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
@@ -618,8 +608,6 @@ golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8U
 golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 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=
@@ -680,7 +668,6 @@ golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20191112182307-2180aed22343/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -704,7 +691,6 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
 golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=
 golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
 golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
-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.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
@@ -751,7 +737,6 @@ golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191112214154-59a1497f0cea/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=

+ 203 - 343
pkg/storage/azurestorage.go

@@ -1,27 +1,29 @@
 package storage
 
-// Fork from Thanos S3 Bucket support to reuse configuration options
+// Fork from Thanos Azure Storage Bucket support to reuse configuration options
 // Licensed under the Apache License 2.0
-// https://github.com/thanos-io/thanos/blob/main/pkg/objstore/s3/s3.go
+// https://github.com/thanos-io/objstore/blob/main/providers/azure/azure.go
 
 import (
 	"bytes"
 	"context"
 	"fmt"
-	"io"
 	"net"
 	"net/http"
-	"net/url"
 	"strings"
-	"sync"
 	"time"
 
+	"github.com/Azure/azure-sdk-for-go/sdk/azcore"
+	"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
+	"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
+	"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
+	"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob"
+	"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/blob"
+	"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/bloberror"
+	"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/blockblob"
+	"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/container"
 	"github.com/opencost/opencost/core/pkg/log"
 
-	"github.com/Azure/azure-pipeline-go/pipeline"
-	blob "github.com/Azure/azure-storage-blob-go/azblob"
-	"github.com/Azure/go-autorest/autorest/adal"
-	"github.com/Azure/go-autorest/autorest/azure/auth"
 	"github.com/pkg/errors"
 	"github.com/prometheus/common/model"
 	"gopkg.in/yaml.v2"
@@ -54,29 +56,19 @@ var defaultAzureConfig = AzureConfig{
 	},
 }
 
-func init() {
-	// Disable `ForceLog` in Azure storage module
-	// As the time of this patch, the logging function in the storage module isn't correctly
-	// detecting expected REST errors like 404 and so outputs them to syslog along with a stacktrace.
-	// https://github.com/Azure/azure-storage-blob-go/issues/214
-	//
-	// This needs to be done at startup because the underlying variable is not thread safe.
-	// https://github.com/Azure/azure-pipeline-go/blob/dc95902f1d32034f8f743ccc6c3f2eb36b84da27/pipeline/core.go#L276-L283
-	pipeline.SetForceLogEnabled(false)
-}
-
 // AzureConfig Azure storage configuration.
 type AzureConfig struct {
-	StorageAccountName string          `yaml:"storage_account"`
-	StorageAccountKey  string          `yaml:"storage_account_key"`
-	ContainerName      string          `yaml:"container"`
-	Endpoint           string          `yaml:"endpoint"`
-	MaxRetries         int             `yaml:"max_retries"`
-	MSIResource        string          `yaml:"msi_resource"`
-	UserAssignedID     string          `yaml:"user_assigned_id"`
-	PipelineConfig     PipelineConfig  `yaml:"pipeline_config"`
-	ReaderConfig       ReaderConfig    `yaml:"reader_config"`
-	HTTPConfig         AzureHTTPConfig `yaml:"http_config"`
+	StorageAccountName      string          `yaml:"storage_account"`
+	StorageAccountKey       string          `yaml:"storage_account_key"`
+	StorageConnectionString string          `yaml:"storage_connection_string"`
+	ContainerName           string          `yaml:"container"`
+	Endpoint                string          `yaml:"endpoint"`
+	MaxRetries              int             `yaml:"max_retries"`
+	MSIResource             string          `yaml:"msi_resource"`
+	UserAssignedID          string          `yaml:"user_assigned_id"`
+	PipelineConfig          PipelineConfig  `yaml:"pipeline_config"`
+	ReaderConfig            ReaderConfig    `yaml:"reader_config"`
+	HTTPConfig              AzureHTTPConfig `yaml:"http_config"`
 }
 
 type ReaderConfig struct {
@@ -107,48 +99,32 @@ type AzureHTTPConfig struct {
 
 // AzureStorage implements the storage.Storage interface against Azure APIs.
 type AzureStorage struct {
-	name         string
-	containerURL blob.ContainerURL
-	config       *AzureConfig
+	name            string
+	containerClient *container.Client
+	config          *AzureConfig
 }
 
 // Validate checks to see if any of the config options are set.
 func (conf *AzureConfig) validate() error {
 	var errMsg []string
-	if conf.MSIResource == "" {
-		if conf.UserAssignedID == "" {
-			if conf.StorageAccountName == "" ||
-				conf.StorageAccountKey == "" {
-				errMsg = append(errMsg, "invalid Azure storage configuration")
-			}
-			if conf.StorageAccountName == "" && conf.StorageAccountKey != "" {
-				errMsg = append(errMsg, "no Azure storage_account specified while storage_account_key is present in config file; both should be present")
-			}
-			if conf.StorageAccountName != "" && conf.StorageAccountKey == "" {
-				errMsg = append(errMsg, "no Azure storage_account_key specified while storage_account is present in config file; both should be present")
-			}
-		} else {
-			if conf.StorageAccountName == "" {
-				errMsg = append(errMsg, "UserAssignedID is configured but storage account name is missing")
-			}
-			if conf.StorageAccountKey != "" {
-				errMsg = append(errMsg, "UserAssignedID is configured but storage account key is used")
-			}
-		}
-	} else {
-		if conf.StorageAccountName == "" {
-			errMsg = append(errMsg, "MSI resource is configured but storage account name is missing")
-		}
-		if conf.StorageAccountKey != "" {
-			errMsg = append(errMsg, "MSI resource is configured but storage account key is used")
-		}
+	if conf.UserAssignedID != "" && conf.StorageAccountKey != "" {
+		errMsg = append(errMsg, "user_assigned_id cannot be set when using storage_account_key authentication")
 	}
 
-	if conf.ContainerName == "" {
-		errMsg = append(errMsg, "no Azure container specified")
+	if conf.UserAssignedID != "" && conf.StorageConnectionString != "" {
+		errMsg = append(errMsg, "user_assigned_id cannot be set when using storage_connection_string authentication")
 	}
-	if conf.Endpoint == "" {
-		conf.Endpoint = azureDefaultEndpoint
+
+	if conf.StorageAccountKey != "" && conf.StorageConnectionString != "" {
+		errMsg = append(errMsg, "storage_account_key and storage_connection_string cannot both be set")
+	}
+
+	if conf.StorageAccountName == "" {
+		errMsg = append(errMsg, "storage_account_name is required but not configured")
+	}
+
+	if conf.ContainerName == "" {
+		errMsg = append(errMsg, "no container specified")
 	}
 
 	if conf.PipelineConfig.MaxTries < 0 {
@@ -193,7 +169,7 @@ func NewAzureStorage(azureConfig []byte) (*AzureStorage, error) {
 
 	conf, err := parseAzureConfig(azureConfig)
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("error parsing azure storage config: %w", err)
 	}
 
 	return NewAzureStorageWith(conf)
@@ -202,33 +178,32 @@ func NewAzureStorage(azureConfig []byte) (*AzureStorage, error) {
 // NewAzureStorageWith returns a new Storage using the provided Azure config struct.
 func NewAzureStorageWith(conf AzureConfig) (*AzureStorage, error) {
 	if err := conf.validate(); err != nil {
-		return nil, err
+		return nil, fmt.Errorf("error validating azure storage config: %w", err)
 	}
 
+	containerClient, err := getContainerClient(conf)
+	if err != nil {
+		return nil, fmt.Errorf("error retrieving container client: %w", err)
+	}
+
+	// Check if storage account container already exists, and create one if it does not.
 	ctx := context.Background()
-	container, err := createContainer(ctx, conf)
+	_, err = containerClient.GetProperties(ctx, &container.GetPropertiesOptions{})
 	if err != nil {
-		ret, ok := err.(blob.StorageError)
-		if !ok {
-			return nil, errors.Wrapf(err, "Azure API return unexpected error: %T\n", err)
+		if !bloberror.HasCode(err, bloberror.ContainerNotFound) {
+			return nil, err
 		}
-		if ret.ServiceCode() == "ContainerAlreadyExists" {
-			log.Debugf("Getting connection to existing Azure blob container: %s", conf.ContainerName)
-			container, err = getContainer(ctx, conf)
-			if err != nil {
-				return nil, errors.Wrapf(err, "cannot get existing Azure blob container: %s", container)
-			}
-		} else {
-			return nil, errors.Wrapf(err, "error creating Azure blob container: %s", container)
+		_, err := containerClient.Create(ctx, nil)
+		if err != nil {
+			return nil, errors.Wrapf(err, "error creating Azure blob container: %s", conf.ContainerName)
 		}
-	} else {
-		log.Infof("Azure blob container successfully created. Address: %s", container)
+		log.Infof("Azure blob container successfully created %s", conf.ContainerName)
 	}
 
 	return &AzureStorage{
-		name:         conf.ContainerName,
-		containerURL: container,
-		config:       &conf,
+		name:            conf.ContainerName,
+		containerClient: containerClient,
+		config:          &conf,
 	}, nil
 }
 
@@ -253,17 +228,16 @@ func (as *AzureStorage) FullPath(name string) string {
 func (b *AzureStorage) Stat(name string) (*StorageInfo, error) {
 	name = trimLeading(name)
 	ctx := context.Background()
-
-	blobURL := getBlobURL(name, b.containerURL)
-	props, err := blobURL.GetProperties(ctx, blob.BlobAccessConditions{}, blob.ClientProvidedKeyOptions{})
+	blobClient := b.containerClient.NewBlobClient(name)
+	props, err := blobClient.GetProperties(ctx, nil)
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("error retrieving blob properties: %w", err)
 	}
 
 	return &StorageInfo{
 		Name:    trimName(name),
-		Size:    props.ContentLength(),
-		ModTime: props.LastModified(),
+		Size:    *props.ContentLength,
+		ModTime: *props.LastModified,
 	}, nil
 }
 
@@ -275,17 +249,25 @@ func (b *AzureStorage) Read(name string) ([]byte, error) {
 
 	log.Debugf("AzureStorage::Read(%s)", name)
 
-	reader, err := b.getBlobReader(ctx, name, 0, blob.CountToEnd)
+	downloadResponse, err := b.containerClient.NewBlobClient(name).DownloadStream(ctx, nil)
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("AzureStorage: Read: failed to download %w", err)
 	}
+	// NOTE: automatically retries are performed if the connection fails
+	retryReader := downloadResponse.NewRetryReader(ctx, &azblob.RetryReaderOptions{
+		MaxRetries: int32(b.config.ReaderConfig.MaxRetryRequests),
+	})
+	defer retryReader.Close()
+
+	// read the body into a buffer
+	downloadedData := bytes.Buffer{}
 
-	data, err := io.ReadAll(reader)
+	_, err = downloadedData.ReadFrom(retryReader)
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("AzureStorage: Read: failed to read downloaded data %w", err)
 	}
 
-	return data, nil
+	return downloadedData.Bytes(), nil
 }
 
 // Write uses the relative path of the storage combined with the provided path
@@ -296,14 +278,13 @@ func (b *AzureStorage) Write(name string, data []byte) error {
 
 	log.Debugf("AzureStorage::Write(%s)", name)
 
-	blobURL := getBlobURL(name, b.containerURL)
 	r := bytes.NewReader(data)
-	if _, err := blob.UploadStreamToBlockBlob(ctx, r, blobURL,
-		blob.UploadStreamToBlockBlobOptions{
-			BufferSize: len(data),
-			MaxBuffers: 1,
-		},
-	); err != nil {
+	blobClient := b.containerClient.NewBlockBlobClient(name)
+	opts := &blockblob.UploadStreamOptions{
+		BlockSize:   3 * 1024 * 1024,
+		Concurrency: 4,
+	}
+	if _, err := blobClient.UploadStream(ctx, r, opts); err != nil {
 		return errors.Wrapf(err, "cannot upload Azure blob, address: %s", name)
 	}
 	return nil
@@ -317,8 +298,11 @@ func (b *AzureStorage) Remove(name string) error {
 	log.Debugf("AzureStorage::Remove(%s)", name)
 	ctx := context.Background()
 
-	blobURL := getBlobURL(name, b.containerURL)
-	if _, err := blobURL.Delete(ctx, blob.DeleteSnapshotsOptionInclude, blob.BlobAccessConditions{}); err != nil {
+	blobClient := b.containerClient.NewBlobClient(name)
+	opt := &blob.DeleteOptions{
+		DeleteSnapshots: to.Ptr(blob.DeleteSnapshotsOptionTypeInclude),
+	}
+	if _, err := blobClient.Delete(ctx, opt); err != nil {
 		return errors.Wrapf(err, "error deleting blob, address: %s", name)
 	}
 	return nil
@@ -329,16 +313,13 @@ func (b *AzureStorage) Remove(name string) error {
 func (b *AzureStorage) Exists(name string) (bool, error) {
 	name = trimLeading(name)
 	ctx := context.Background()
-	blobURL := getBlobURL(name, b.containerURL)
-	if _, err := blobURL.GetProperties(ctx, blob.BlobAccessConditions{}, blob.ClientProvidedKeyOptions{}); err != nil {
-		var se blob.StorageError
-		if errors.As(err, &se) && se.ServiceCode() == blob.ServiceCodeBlobNotFound {
+	blobClient := b.containerClient.NewBlobClient(name)
+	if _, err := blobClient.GetProperties(ctx, nil); err != nil {
+		if b.IsObjNotFoundErr(err) {
 			return false, nil
 		}
-
 		return false, errors.Wrapf(err, "cannot get properties for Azure blob, address: %s", name)
 	}
-
 	return true, nil
 }
 
@@ -356,61 +337,38 @@ func (b *AzureStorage) List(path string) ([]*StorageInfo, error) {
 		path = strings.TrimSuffix(path, DirDelim) + DirDelim
 	}
 
-	marker := blob.Marker{}
-	listOptions := blob.ListBlobsSegmentOptions{Prefix: path}
-
-	var names []string
-	for i := 1; ; i++ {
-		var blobItems []blob.BlobItemInternal
-
-		list, err := b.containerURL.ListBlobsHierarchySegment(ctx, marker, DirDelim, listOptions)
+	var stats []*StorageInfo
+	list := b.containerClient.NewListBlobsHierarchyPager(DirDelim, &container.ListBlobsHierarchyOptions{
+		Prefix: &path,
+	})
+	for list.More() {
+		page, err := list.NextPage(ctx)
 		if err != nil {
-			return nil, errors.Wrapf(err, "cannot list hierarchy blobs with prefix %s (iteration #%d)", path, i)
+			return nil, fmt.Errorf("failed to retrieve page: %s", err)
 		}
-
-		marker = list.NextMarker
-		blobItems = list.Segment.BlobItems
-
-		for _, blob := range blobItems {
-			names = append(names, blob.Name)
+		segment := page.ListBlobsHierarchySegmentResponse.Segment
+		if segment == nil {
+			continue
 		}
-
-		// Continue iterating if we are not done.
-		if !marker.NotDone() {
-			break
-		}
-
-		log.Debugf("Requesting next iteration of listing blobs. Entries: %d, iteration: %d", len(names), i)
-	}
-
-	// get the storage information for each blob (really unfortunate we have to do this)
-	var lock sync.Mutex
-	var stats []*StorageInfo
-	var wg sync.WaitGroup
-	wg.Add(len(names))
-
-	for i := 0; i < len(names); i++ {
-		go func(n string) {
-			defer wg.Done()
-
-			stat, err := b.Stat(n)
-			if err != nil {
-				log.Errorf("Error statting blob %s: %s", n, err)
-			} else {
-				lock.Lock()
-				stats = append(stats, stat)
-				lock.Unlock()
+		for _, blob := range segment.BlobItems {
+			if blob.Name == nil {
+				continue
 			}
-		}(names[i])
+			if blob.Properties == nil {
+				continue
+			}
+			stats = append(stats, &StorageInfo{
+				Name:    trimName(*blob.Name),
+				Size:    *blob.Properties.ContentLength,
+				ModTime: *blob.Properties.LastModified,
+			})
+		}
 	}
 
-	wg.Wait()
-
 	return stats, nil
 }
 
 func (b *AzureStorage) ListDirectories(path string) ([]*StorageInfo, error) {
-
 	path = trimLeading(path)
 
 	log.Debugf("AzureStorage::ListDirectories(%s)", path)
@@ -422,198 +380,53 @@ func (b *AzureStorage) ListDirectories(path string) ([]*StorageInfo, error) {
 		path = strings.TrimSuffix(path, DirDelim) + DirDelim
 	}
 
-	marker := blob.Marker{}
-	listOptions := blob.ListBlobsSegmentOptions{Prefix: path}
-
 	var stats []*StorageInfo
-	for i := 1; ; i++ {
-		var blobPrefixes []blob.BlobPrefix
-
-		list, err := b.containerURL.ListBlobsHierarchySegment(ctx, marker, DirDelim, listOptions)
+	list := b.containerClient.NewListBlobsHierarchyPager(DirDelim, &container.ListBlobsHierarchyOptions{
+		Prefix: &path,
+	})
+	for list.More() {
+		page, err := list.NextPage(ctx)
 		if err != nil {
-			return nil, errors.Wrapf(err, "cannot list hierarchy blobs with prefix %s (iteration #%d)", path, i)
+			return nil, fmt.Errorf("failed to retrieve page: %s", err)
 		}
+		segment := page.ListBlobsHierarchySegmentResponse.Segment
+		if segment == nil {
+			continue
+		}
+		for _, dir := range segment.BlobPrefixes {
+			if dir.Name == nil {
+				continue
+			}
 
-		marker = list.NextMarker
-		blobPrefixes = list.Segment.BlobPrefixes
-
-		for _, prefix := range blobPrefixes {
 			stats = append(stats, &StorageInfo{
-				Name: trimLeading(prefix.Name),
+				Name: *dir.Name,
 			})
 		}
-
-		// Continue iterating if we are not done.
-		if !marker.NotDone() {
-			break
-		}
-
-		log.Debugf("Requesting next iteration of listing blobs. Entries: %d, iteration: %d", len(stats), i)
 	}
 
 	return stats, nil
 }
 
-func (b *AzureStorage) getBlobReader(ctx context.Context, name string, offset, length int64) (io.ReadCloser, error) {
-	log.Debugf("Getting blob: %s, offset: %d, length: %d", name, offset, length)
-	if name == "" {
-		return nil, errors.New("X-Ms-Error-Code: [EmptyContainerName]")
-	}
-	exists, err := b.Exists(name)
-	if err != nil {
-		return nil, errors.Wrapf(err, "cannot get blob reader: %s", name)
-	}
-
-	if !exists {
-		return nil, errors.New("X-Ms-Error-Code: [BlobNotFound]")
-	}
-
-	blobURL := getBlobURL(name, b.containerURL)
-	if err != nil {
-		return nil, errors.Wrapf(err, "cannot get Azure blob URL, address: %s", name)
-	}
-	var props *blob.BlobGetPropertiesResponse
-	props, err = blobURL.GetProperties(ctx, blob.BlobAccessConditions{}, blob.ClientProvidedKeyOptions{})
-	if err != nil {
-		return nil, errors.Wrapf(err, "cannot get properties for container: %s", name)
-	}
-
-	var size int64
-	// If a length is specified and it won't go past the end of the file,
-	// then set it as the size.
-	if length > 0 && length <= props.ContentLength()-offset {
-		size = length
-		log.Debugf("set size to length. size: %d, length: %d, offset: %d, name: %s", size, length, offset, name)
-	} else {
-		size = props.ContentLength() - offset
-		log.Debugf("set size to go to EOF. contentlength: %d, size: %d, length: %d, offset: %d, name: %s", props.ContentLength(), size, length, offset, name)
-	}
-
-	destBuffer := make([]byte, size)
-
-	if err := blob.DownloadBlobToBuffer(context.Background(), blobURL.BlobURL, offset, size,
-		destBuffer, blob.DownloadFromBlobOptions{
-			BlockSize:   blob.BlobDefaultDownloadBlockSize,
-			Parallelism: uint16(3),
-			Progress:    nil,
-			RetryReaderOptionsPerBlock: blob.RetryReaderOptions{
-				MaxRetryRequests: b.config.ReaderConfig.MaxRetryRequests,
-			},
-		},
-	); err != nil {
-		return nil, errors.Wrapf(err, "cannot download blob, address: %s", blobURL.BlobURL)
-	}
-
-	return io.NopCloser(bytes.NewReader(destBuffer)), nil
-}
-
-func getAzureStorageCredentials(conf AzureConfig) (blob.Credential, error) {
-	if conf.MSIResource != "" || conf.UserAssignedID != "" {
-		spt, err := getServicePrincipalToken(conf)
-		if err != nil {
-			return nil, err
-		}
-		if err := spt.Refresh(); err != nil {
-			return nil, err
-		}
-
-		return blob.NewTokenCredential(spt.Token().AccessToken, func(tc blob.TokenCredential) time.Duration {
-			err := spt.Refresh()
-			if err != nil {
-				log.Errorf("could not refresh MSI token. err: %s", err)
-				// Retry later as the error can be related to API throttling
-				return 30 * time.Second
-			}
-			tc.SetToken(spt.Token().AccessToken)
-			return spt.Token().Expires().Sub(time.Now().Add(2 * time.Minute))
-		}), nil
+// IsObjNotFoundErr returns true if error means that object is not found. Relevant to Get operations.
+func (b *AzureStorage) IsObjNotFoundErr(err error) bool {
+	if err == nil {
+		return false
 	}
-
-	credential, err := blob.NewSharedKeyCredential(conf.StorageAccountName, conf.StorageAccountKey)
-	if err != nil {
-		return nil, err
-	}
-	return credential, nil
+	return bloberror.HasCode(err, bloberror.BlobNotFound) || bloberror.HasCode(err, bloberror.InvalidURI)
 }
 
-func getServicePrincipalToken(conf AzureConfig) (*adal.ServicePrincipalToken, error) {
-	resource := conf.MSIResource
-	if resource == "" {
-		resource = fmt.Sprintf("https://%s.%s", conf.StorageAccountName, conf.Endpoint)
-	}
-
-	msiConfig := auth.MSIConfig{
-		Resource: resource,
+// IsAccessDeniedErr returns true if access to object is denied.
+func (b *AzureStorage) IsAccessDeniedErr(err error) bool {
+	if err == nil {
+		return false
 	}
-
-	if conf.UserAssignedID != "" {
-		log.Debugf("using user assigned identity. clientId: %s", conf.UserAssignedID)
-		msiConfig.ClientID = conf.UserAssignedID
-	} else {
-		log.Debugf("using system assigned identity")
-	}
-
-	return msiConfig.ServicePrincipalToken()
-}
-
-func getContainerURL(ctx context.Context, conf AzureConfig) (blob.ContainerURL, error) {
-	credentials, err := getAzureStorageCredentials(conf)
-
-	if err != nil {
-		return blob.ContainerURL{}, err
-	}
-
-	retryOptions := blob.RetryOptions{
-		MaxTries:      conf.PipelineConfig.MaxTries,
-		TryTimeout:    time.Duration(conf.PipelineConfig.TryTimeout),
-		RetryDelay:    time.Duration(conf.PipelineConfig.RetryDelay),
-		MaxRetryDelay: time.Duration(conf.PipelineConfig.MaxRetryDelay),
-	}
-
-	if deadline, ok := ctx.Deadline(); ok {
-		retryOptions.TryTimeout = time.Until(deadline)
-	}
-
-	dt, err := DefaultAzureTransport(conf)
-	if err != nil {
-		return blob.ContainerURL{}, err
-	}
-	client := http.Client{
-		Transport: dt,
-	}
-
-	p := blob.NewPipeline(credentials, blob.PipelineOptions{
-		Retry:     retryOptions,
-		Telemetry: blob.TelemetryOptions{Value: "Kubecost"},
-		RequestLog: blob.RequestLogOptions{
-			// Log a warning if an operation takes longer than the specified duration.
-			// (-1=no logging; 0=default 3s threshold)
-			LogWarningIfTryOverThreshold: -1,
-		},
-		Log: pipeline.LogOptions{
-			ShouldLog: nil,
-		},
-		HTTPSender: pipeline.FactoryFunc(func(next pipeline.Policy, po *pipeline.PolicyOptions) pipeline.PolicyFunc {
-			return func(ctx context.Context, request pipeline.Request) (pipeline.Response, error) {
-				resp, err := client.Do(request.WithContext(ctx))
-
-				return pipeline.NewHTTPResponse(resp), err
-			}
-		}),
-	})
-	u, err := url.Parse(fmt.Sprintf("https://%s.%s", conf.StorageAccountName, conf.Endpoint))
-	if err != nil {
-		return blob.ContainerURL{}, err
-	}
-	service := blob.NewServiceURL(*u, p)
-
-	return service.NewContainerURL(conf.ContainerName), nil
+	return bloberror.HasCode(err, bloberror.AuthorizationPermissionMismatch) || bloberror.HasCode(err, bloberror.InsufficientAccountPermissions)
 }
 
 func DefaultAzureTransport(config AzureConfig) (*http.Transport, error) {
 	tlsConfig, err := NewTLSConfig(&config.HTTPConfig.TLSConfig)
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("error creating TLS config: %w", err)
 	}
 
 	if config.HTTPConfig.InsecureSkipVerify {
@@ -640,28 +453,75 @@ func DefaultAzureTransport(config AzureConfig) (*http.Transport, error) {
 	}, nil
 }
 
-func getContainer(ctx context.Context, conf AzureConfig) (blob.ContainerURL, error) {
-	c, err := getContainerURL(ctx, conf)
+func getContainerClient(conf AzureConfig) (*container.Client, error) {
+	dt, err := DefaultAzureTransport(conf)
 	if err != nil {
-		return blob.ContainerURL{}, err
+		return nil, fmt.Errorf("error creating default transport: %w", err)
+	}
+	opt := &container.ClientOptions{
+		ClientOptions: azcore.ClientOptions{
+			Retry: policy.RetryOptions{
+				MaxRetries:    conf.PipelineConfig.MaxTries,
+				TryTimeout:    time.Duration(conf.PipelineConfig.TryTimeout),
+				RetryDelay:    time.Duration(conf.PipelineConfig.RetryDelay),
+				MaxRetryDelay: time.Duration(conf.PipelineConfig.MaxRetryDelay),
+			},
+			Telemetry: policy.TelemetryOptions{
+				ApplicationID: "Thanos",
+			},
+			Transport: &http.Client{Transport: dt},
+		},
+	}
+
+	// Use connection string if set
+	if conf.StorageConnectionString != "" {
+		containerClient, err := container.NewClientFromConnectionString(conf.StorageConnectionString, conf.ContainerName, opt)
+		if err != nil {
+			return nil, fmt.Errorf("error creating client from connection string: %w", err)
+		}
+		return containerClient, nil
+	}
+
+	if conf.Endpoint == "" {
+		conf.Endpoint = "blob.core.windows.net"
+	}
+
+	containerURL := fmt.Sprintf("https://%s.%s/%s", conf.StorageAccountName, conf.Endpoint, conf.ContainerName)
+
+	// Use shared keys if set
+	if conf.StorageAccountKey != "" {
+		cred, err := container.NewSharedKeyCredential(conf.StorageAccountName, conf.StorageAccountKey)
+		if err != nil {
+			return nil, fmt.Errorf("error getting shared key credential: %w", err)
+		}
+		containerClient, err := container.NewClientWithSharedKeyCredential(containerURL, cred, opt)
+		if err != nil {
+			return nil, fmt.Errorf("error creating client with shared key credential: %w", err)
+		}
+		return containerClient, nil
+	}
+
+	// Otherwise use a token credential
+	var cred azcore.TokenCredential
+
+	// Use Managed Identity Credential if a user assigned ID is set
+	if conf.UserAssignedID != "" {
+		msiOpt := &azidentity.ManagedIdentityCredentialOptions{}
+		msiOpt.ID = azidentity.ClientID(conf.UserAssignedID)
+		cred, err = azidentity.NewManagedIdentityCredential(msiOpt)
+	} else {
+		// Otherwise use Default Azure Credential
+		cred, err = azidentity.NewDefaultAzureCredential(nil)
 	}
-	// Getting container properties to check if it exists or not. Returns error which will be parsed further.
-	_, err = c.GetProperties(ctx, blob.LeaseAccessConditions{})
-	return c, err
-}
 
-func createContainer(ctx context.Context, conf AzureConfig) (blob.ContainerURL, error) {
-	c, err := getContainerURL(ctx, conf)
 	if err != nil {
-		return blob.ContainerURL{}, err
+		return nil, fmt.Errorf("error creating token credential: %w", err)
+	}
+
+	containerClient, err := container.NewClient(containerURL, cred, opt)
+	if err != nil {
+		return nil, fmt.Errorf("error creating client from token credential: %w", err)
 	}
-	_, err = c.Create(
-		ctx,
-		blob.Metadata{},
-		blob.PublicAccessNone)
-	return c, err
-}
 
-func getBlobURL(blobName string, c blob.ContainerURL) blob.BlockBlobURL {
-	return c.NewBlockBlobURL(blobName)
+	return containerClient, nil
 }

+ 489 - 0
pkg/storage/bucketstorage_test.go

@@ -0,0 +1,489 @@
+package storage
+
+import (
+	"fmt"
+	"os"
+	"path"
+	"testing"
+
+	"github.com/opencost/opencost/core/pkg/util/json"
+)
+
+// This suite of integration tests is meant to validate if an implementation of Storage that relies on a could
+// bucket service properly implements the interface. To run these tests the env variable "TEST_BUCKET_CONFIG"
+// must be set with the path to a valid bucket config as defined in the NewBucketStorage() function.
+
+type testFileContent struct {
+	Field1 int    `json:"field_1"`
+	Field2 string `json:"field_2"`
+}
+
+var tfc = testFileContent{
+	Field1: 101,
+	Field2: "TEST_FILE_CONTENT",
+}
+
+const testpath = "opencost/storage/"
+
+func createStorage(configPath string) (Storage, error) {
+
+	bytes, err := os.ReadFile(configPath)
+	if err != nil {
+		return nil, fmt.Errorf("failed to read config file: %w", err)
+	}
+	store, err := NewBucketStorage(bytes)
+	if err != nil {
+		return nil, fmt.Errorf("failed to initialize storage: %w", err)
+	}
+
+	return store, nil
+}
+
+func createFiles(files []string, testName string, store Storage) error {
+	b, err := json.Marshal(tfc)
+	if err != nil {
+		return fmt.Errorf("failed to marshal file content: %w", err)
+	}
+
+	for _, fileName := range files {
+		filePath := path.Join(testpath, testName, fileName)
+		err = store.Write(filePath, b)
+		if err != nil {
+			return fmt.Errorf("failed to write file '%s': %w ", filePath, err)
+		}
+	}
+
+	return nil
+}
+
+func cleanupFiles(files []string, testName string, store Storage) error {
+	for _, fileName := range files {
+		filePath := path.Join(testpath, testName, fileName)
+		err := store.Remove(filePath)
+		if err != nil {
+			return fmt.Errorf("failed to remove file '%s': %w ", filePath, err)
+		}
+	}
+
+	return nil
+}
+
+func TestBucketStorage_List(t *testing.T) {
+	configPath := os.Getenv("TEST_BUCKET_CONFIG")
+	if configPath == "" {
+		t.Skip("skipping integration test, set environment variable TEST_BUCKET_CONFIG")
+	}
+	store, err := createStorage(configPath)
+	if err != nil {
+		t.Errorf("failed to create storage: %s", err.Error())
+		return
+	}
+
+	testName := "list"
+
+	fileNames := []string{
+		"/file0.json",
+		"/file1.json",
+		"/dir0/file2.json",
+		"/dir0/file3.json",
+	}
+
+	err = createFiles(fileNames, testName, store)
+	if err != nil {
+		t.Errorf("failed to create files: %s", err)
+	}
+
+	defer func() {
+		err = cleanupFiles(fileNames, testName, store)
+		if err != nil {
+			t.Errorf("failed to clean up files: %s", err)
+		}
+	}()
+
+	testCases := map[string]struct {
+		path      string
+		expected  []string
+		expectErr bool
+	}{
+		"base dir files": {
+			path: path.Join(testpath, testName),
+			expected: []string{
+				"file0.json",
+				"file1.json",
+			},
+			expectErr: false,
+		},
+		"single nested dir files": {
+			path: path.Join(testpath, testName, "dir0"),
+			expected: []string{
+				"file2.json",
+				"file3.json",
+			},
+			expectErr: false,
+		},
+		"nonexistent dir files": {
+			path:      path.Join(testpath, testName, "dir1"),
+			expected:  []string{},
+			expectErr: false,
+		},
+	}
+
+	for name, tc := range testCases {
+		t.Run(name, func(t *testing.T) {
+			fileList, err := store.List(tc.path)
+			if tc.expectErr == (err == nil) {
+				if tc.expectErr {
+					t.Errorf("expected error was not thrown")
+					return
+				}
+				t.Errorf("unexpected error: %s", err.Error())
+				return
+			}
+
+			if len(fileList) != len(tc.expected) {
+				t.Errorf("file list length does not match expected length, actual: %d, expected: %d", len(fileList), len(tc.expected))
+			}
+
+			expectedSet := map[string]struct{}{}
+			for _, expName := range tc.expected {
+				expectedSet[expName] = struct{}{}
+			}
+
+			for _, file := range fileList {
+				_, ok := expectedSet[file.Name]
+				if !ok {
+					t.Errorf("unexpect file in list %s", file.Name)
+				}
+
+				if file.Size == 0 {
+					t.Errorf("file size is not set")
+				}
+
+				if file.ModTime.IsZero() {
+					t.Errorf("file mod time is not set")
+				}
+			}
+		})
+	}
+}
+
+func TestBucketStorage_ListDirectories(t *testing.T) {
+	configPath := os.Getenv("TEST_BUCKET_CONFIG")
+	if configPath == "" {
+		t.Skip("skipping integration test, set environment variable TEST_BUCKET_CONFIG")
+	}
+	store, err := createStorage(configPath)
+	if err != nil {
+		t.Errorf("failed to create storage: %s", err.Error())
+		return
+	}
+
+	testName := "list_directories"
+
+	fileNames := []string{
+		"/file0.json",
+		"/dir0/file2.json",
+		"/dir0/file3.json",
+		"/dir0/dir1/file4.json",
+		"/dir0/dir2/file5.json",
+	}
+
+	err = createFiles(fileNames, testName, store)
+	if err != nil {
+		t.Errorf("failed to create files: %s", err)
+	}
+
+	defer func() {
+		err = cleanupFiles(fileNames, testName, store)
+		if err != nil {
+			t.Errorf("failed to clean up files: %s", err)
+		}
+	}()
+
+	testCases := map[string]struct {
+		path      string
+		expected  []string
+		expectErr bool
+	}{
+		"base dir dir": {
+			path: path.Join(testpath, testName),
+			expected: []string{
+				path.Join(testpath, testName, "dir0") + "/",
+			},
+			expectErr: false,
+		},
+		"single nested dir files": {
+			path: path.Join(testpath, testName, "dir0"),
+			expected: []string{
+				path.Join(testpath, testName, "dir0", "dir1") + "/",
+				path.Join(testpath, testName, "dir0", "dir2") + "/",
+			},
+			expectErr: false,
+		},
+		"dir with no sub dirs": {
+			path:      path.Join(testpath, testName, "dir0/dir1"),
+			expected:  []string{},
+			expectErr: false,
+		},
+		"non-existent dir": {
+			path:      path.Join(testpath, testName, "dir1"),
+			expected:  []string{},
+			expectErr: false,
+		},
+	}
+
+	for name, tc := range testCases {
+		t.Run(name, func(t *testing.T) {
+			dirList, err := store.ListDirectories(tc.path)
+			if tc.expectErr == (err == nil) {
+				if tc.expectErr {
+					t.Errorf("expected error was not thrown")
+					return
+				}
+				t.Errorf("unexpected error: %s", err.Error())
+				return
+			}
+
+			if len(dirList) != len(tc.expected) {
+				t.Errorf("dir list length does not match expected length, actual: %d, expected: %d", len(dirList), len(tc.expected))
+			}
+
+			expectedSet := map[string]struct{}{}
+			for _, expName := range tc.expected {
+				expectedSet[expName] = struct{}{}
+			}
+
+			for _, dir := range dirList {
+				_, ok := expectedSet[dir.Name]
+				if !ok {
+					t.Errorf("unexpect dir in list %s", dir.Name)
+				}
+			}
+		})
+	}
+}
+
+func TestBucketStorage_Exists(t *testing.T) {
+	configPath := os.Getenv("TEST_BUCKET_CONFIG")
+	if configPath == "" {
+		t.Skip("skipping integration test, set environment variable TEST_BUCKET_CONFIG")
+	}
+	store, err := createStorage(configPath)
+	if err != nil {
+		t.Errorf("failed to create storage: %s", err.Error())
+		return
+	}
+
+	testName := "exists"
+
+	fileNames := []string{
+		"/file0.json",
+	}
+
+	err = createFiles(fileNames, testName, store)
+	if err != nil {
+		t.Errorf("failed to create files: %s", err)
+	}
+
+	defer func() {
+		err = cleanupFiles(fileNames, testName, store)
+		if err != nil {
+			t.Errorf("failed to clean up files: %s", err)
+		}
+	}()
+
+	testCases := map[string]struct {
+		path      string
+		expected  bool
+		expectErr bool
+	}{
+		"file exists": {
+			path:      path.Join(testpath, testName, "file0.json"),
+			expected:  true,
+			expectErr: false,
+		},
+		"file does not exist": {
+			path:      path.Join(testpath, testName, "file1.json"),
+			expected:  false,
+			expectErr: false,
+		},
+		"dir does not exist": {
+			path:      path.Join(testpath, testName, "dir0/file.json"),
+			expected:  false,
+			expectErr: false,
+		},
+	}
+
+	for name, tc := range testCases {
+		t.Run(name, func(t *testing.T) {
+			exists, err := store.Exists(tc.path)
+			if tc.expectErr == (err == nil) {
+				if tc.expectErr {
+					t.Errorf("expected error was not thrown")
+					return
+				}
+				t.Errorf("unexpected error: %s", err.Error())
+				return
+			}
+
+			if exists != tc.expected {
+				t.Errorf("file exists output did not match expected")
+			}
+		})
+	}
+}
+
+func TestBucketStorage_Read(t *testing.T) {
+	configPath := os.Getenv("TEST_BUCKET_CONFIG")
+	if configPath == "" {
+		t.Skip("skipping integration test, set environment variable TEST_BUCKET_CONFIG")
+	}
+	store, err := createStorage(configPath)
+	if err != nil {
+		t.Errorf("failed to create storage: %s", err.Error())
+		return
+	}
+
+	testName := "read"
+
+	fileNames := []string{
+		"/file0.json",
+	}
+
+	err = createFiles(fileNames, testName, store)
+	if err != nil {
+		t.Errorf("failed to create files: %s", err)
+	}
+
+	defer func() {
+		err = cleanupFiles(fileNames, testName, store)
+		if err != nil {
+			t.Errorf("failed to clean up files: %s", err)
+		}
+	}()
+
+	testCases := map[string]struct {
+		path      string
+		expectErr bool
+	}{
+		"file exists": {
+			path:      path.Join(testpath, testName, "file0.json"),
+			expectErr: false,
+		},
+		"file does not exist": {
+			path:      path.Join(testpath, testName, "file1.json"),
+			expectErr: true,
+		},
+		"dir does not exist": {
+			path:      path.Join(testpath, testName, "dir0/file.json"),
+			expectErr: true,
+		},
+	}
+
+	for name, tc := range testCases {
+		t.Run(name, func(t *testing.T) {
+			b, err := store.Read(tc.path)
+			if tc.expectErr && err != nil {
+				return
+			}
+			if tc.expectErr == (err == nil) {
+				if tc.expectErr {
+					t.Errorf("expected error was not thrown")
+					return
+				}
+				t.Errorf("unexpected error: %s", err.Error())
+				return
+			}
+			var content testFileContent
+			err = json.Unmarshal(b, &content)
+			if err != nil {
+				t.Errorf("could not unmarshal file content")
+				return
+			}
+
+			if content != tfc {
+				t.Errorf("file content did not match writen value")
+			}
+		})
+	}
+}
+
+func TestBucketStorage_Stat(t *testing.T) {
+	configPath := os.Getenv("TEST_BUCKET_CONFIG")
+	if configPath == "" {
+		t.Skip("skipping integration test, set environment variable TEST_BUCKET_CONFIG")
+	}
+	store, err := createStorage(configPath)
+	if err != nil {
+		t.Errorf("failed to create storage: %s", err.Error())
+		return
+	}
+
+	testName := "stat"
+
+	fileNames := []string{
+		"/file0.json",
+	}
+
+	err = createFiles(fileNames, testName, store)
+	if err != nil {
+		t.Errorf("failed to create files: %s", err)
+	}
+
+	defer func() {
+		err = cleanupFiles(fileNames, testName, store)
+		if err != nil {
+			t.Errorf("failed to clean up files: %s", err)
+		}
+	}()
+
+	testCases := map[string]struct {
+		path      string
+		expected  *StorageInfo
+		expectErr bool
+	}{
+		"base dir": {
+			path: path.Join(testpath, testName, "file0.json"),
+			expected: &StorageInfo{
+				Name: "file0.json",
+				Size: 45,
+			},
+			expectErr: false,
+		},
+		"file does not exist": {
+			path:      path.Join(testpath, testName, "file1.json"),
+			expected:  nil,
+			expectErr: true,
+		},
+	}
+
+	for name, tc := range testCases {
+		t.Run(name, func(t *testing.T) {
+			status, err := store.Stat(tc.path)
+			if tc.expectErr && err != nil {
+				return
+			}
+			if tc.expectErr == (err == nil) {
+				if tc.expectErr {
+					t.Errorf("expected error was not thrown")
+					return
+				}
+				t.Errorf("unexpected error: %s", err.Error())
+				return
+			}
+
+			if status.Name != tc.expected.Name {
+				t.Errorf("status name did name match expected, actual: %s, expected: %s", status.Name, tc.expected.Name)
+			}
+
+			if status.Size != tc.expected.Size {
+				t.Errorf("status name did size match expected, actual: %d, expected: %d", status.Size, tc.expected.Size)
+			}
+
+			if status.ModTime.IsZero() {
+				t.Errorf("status mod time is not set")
+			}
+
+		})
+	}
+}

+ 9 - 5
pkg/storage/s3storage.go

@@ -462,17 +462,21 @@ func (s3 *S3Storage) List(path string) ([]*StorageInfo, error) {
 		if object.Err != nil {
 			return nil, object.Err
 		}
-		// This sometimes happens with empty buckets.
-		if object.Key == "" {
-			continue
-		}
+
 		// The s3 client can also return the directory itself in the ListObjects call above.
 		if object.Key == path {
 			continue
 		}
 
+		name := trimName(object.Key)
+
+		// This sometimes happens with empty buckets.
+		if name == "" {
+			continue
+		}
+
 		stats = append(stats, &StorageInfo{
-			Name:    trimName(object.Key),
+			Name:    name,
 			Size:    object.Size,
 			ModTime: object.LastModified,
 		})