diff --git a/Makefile b/Makefile index bc315f6b..7d574630 100644 --- a/Makefile +++ b/Makefile @@ -12,6 +12,9 @@ BUILD_ARGS ?= # Architectures to build images for BUILD_PLATFORMS ?= linux/amd64,linux/arm64,linux/arm/v7 +# Go test arguments, e.g. '-tags=integration' +GO_TEST_ARGS ?= + # Produce CRDs that work back to Kubernetes 1.16 CRD_OPTIONS ?= crd:crdVersions=v1 @@ -93,7 +96,7 @@ build: check-deps $(LIBGIT2) ## Build manager binary KUBEBUILDER_ASSETS?="$(shell $(ENVTEST) --arch=$(ENVTEST_ARCH) use -i $(ENVTEST_KUBERNETES_VERSION) --bin-dir=$(ENVTEST_ASSETS_DIR) -p path)" test: $(LIBGIT2) install-envtest test-api check-deps ## Run tests KUBEBUILDER_ASSETS=$(KUBEBUILDER_ASSETS) \ - go test $(GO_STATIC_FLAGS) ./... -coverprofile cover.out + go test $(GO_STATIC_FLAGS) $(GO_TEST_ARGS) ./... -coverprofile cover.out check-deps: ifeq ($(shell uname -s),Darwin) diff --git a/go.mod b/go.mod index 7653a3a1..748012eb 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,9 @@ replace github.com/fluxcd/source-controller/api => ./api require ( cloud.google.com/go/storage v1.16.0 + github.com/Azure/azure-sdk-for-go/sdk/azcore v0.21.1 + github.com/Azure/azure-sdk-for-go/sdk/azidentity v0.13.1 + github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v0.3.0 github.com/Masterminds/semver/v3 v3.1.1 github.com/ProtonMail/go-crypto v0.0.0-20220113124808-70ae35bab23f github.com/cyphar/filepath-securejoin v0.2.3 @@ -54,7 +57,9 @@ replace helm.sh/helm/v3 v3.8.0 => github.com/hiddeco/helm/v3 v3.8.1-0.2022022311 require ( cloud.google.com/go v0.99.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v0.9.1 // indirect github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect + github.com/AzureAD/microsoft-authentication-library-for-go v0.4.0 // indirect github.com/BurntSushi/toml v0.4.1 // indirect github.com/MakeNowJust/heredoc v0.0.0-20170808103936-bb23615498cd // indirect github.com/Masterminds/goutils v1.1.1 // indirect @@ -96,6 +101,7 @@ require ( github.com/gobwas/glob v0.2.3 // indirect github.com/gofrs/uuid v4.2.0+incompatible // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang-jwt/jwt v3.2.1+incompatible // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.2 // indirect github.com/google/btree v1.0.1 // indirect @@ -146,6 +152,7 @@ require ( github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.0.2 // indirect github.com/peterbourgon/diskv v2.0.1+incompatible // indirect + github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_golang v1.12.1 // indirect diff --git a/go.sum b/go.sum index 942d8556..4f98aa0b 100644 --- a/go.sum +++ b/go.sum @@ -53,7 +53,18 @@ cloud.google.com/go/storage v1.16.0 h1:1UwAux2OZP4310YXg5ohqBEpV16Y93uZG4+qOX7K2 cloud.google.com/go/storage v1.16.0/go.mod h1:ieKBmUyzcftN5tbxwnXClMKH00CfcQ+xL6NN0r5QfmE= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/AdaLogics/go-fuzz-headers v0.0.0-20210715213245-6c3934b029d8/go.mod h1:CzsSbkDixRphAF5hS6wbMKq0eI6ccJRb7/A0M6JBnwg= +github.com/Azure/azure-sdk-for-go v56.3.0+incompatible h1:DmhwMrUIvpeoTDiWRDtNHqelNUd3Og8JCkrLHQK795c= github.com/Azure/azure-sdk-for-go v56.3.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= +github.com/Azure/azure-sdk-for-go/sdk/azcore v0.21.0/go.mod h1:fBF9PQNqB8scdgpZ3ufzaLntG0AG7C1WjPMsiFOmfHM= +github.com/Azure/azure-sdk-for-go/sdk/azcore v0.21.1 h1:qoVeMsc9/fh/yhxVaA0obYjVH/oI/ihrOoMwsLS9KSA= +github.com/Azure/azure-sdk-for-go/sdk/azcore v0.21.1/go.mod h1:fBF9PQNqB8scdgpZ3ufzaLntG0AG7C1WjPMsiFOmfHM= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v0.13.1 h1:RxemzI2cHD0A8WyMqHu/UnDjfpGES/cmjtPbQoktWqs= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v0.13.1/go.mod h1:+nVKciyKD2J9TyVcEQ82Bo9b+3F92PiQfHrIE/zqLqM= +github.com/Azure/azure-sdk-for-go/sdk/internal v0.8.3/go.mod h1:KLF4gFr6DcKFZwSuH8w8yEK6DpFl3LP5rhdvAb7Yz5I= +github.com/Azure/azure-sdk-for-go/sdk/internal v0.9.1 h1:sLZ/Y+P/5RRtsXWylBjB5lkgixYfm0MQPiwrSX//JSo= +github.com/Azure/azure-sdk-for-go/sdk/internal v0.9.1/go.mod h1:KLF4gFr6DcKFZwSuH8w8yEK6DpFl3LP5rhdvAb7Yz5I= +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v0.3.0 h1:Px2UA+2RvSSvv+RvJNuUB6n7rs5Wsel4dXLe90Um2n4= +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v0.3.0/go.mod h1:tPaiy8S5bQ+S5sOiDlINkp7+Ef339+Nz5L5XO+cnOHo= github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= github.com/Azure/go-ansiterm v0.0.0-20210608223527-2377c96fe795/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= @@ -68,6 +79,8 @@ github.com/Azure/go-autorest/autorest/mocks v0.4.1/go.mod h1:LTp+uSrOhSkaKrUy935 github.com/Azure/go-autorest/autorest/to v0.4.0/go.mod h1:fE8iZBn7LQR7zH/9XU2NcPR4o9jEImooCeWJcYV/zLE= github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= +github.com/AzureAD/microsoft-authentication-library-for-go v0.4.0 h1:WVsrXCnHlDDX8ls+tootqRE87/hL9S/g4ewig9RsD/c= +github.com/AzureAD/microsoft-authentication-library-for-go v0.4.0/go.mod h1:Vt9sXTKwMyGcOxSmLDMnGPgqsUg7m8pe215qMLrDXw4= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v0.4.1 h1:GaI7EiDXDRfa8VshkTj7Fym7ha+y8/XxIgD2okUIjLw= github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= @@ -264,6 +277,9 @@ github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8 github.com/distribution/distribution/v3 v3.0.0-20211118083504-a29a3c99a684 h1:DBZ2sN7CK6dgvHVpQsQj4sRMCbWTmd17l+5SUCjnQSY= github.com/distribution/distribution/v3 v3.0.0-20211118083504-a29a3c99a684/go.mod h1:UfCu3YXJJCI+IdnqGgYP82dk2+Joxmv+mUTVBES6wac= github.com/dnaeon/go-vcr v1.0.1/go.mod h1:aBB1+wY4s93YsC3HHjMBMrwTj2R9FHDzUr9KyGc8n1E= +github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko= +github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= +github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/docker/cli v20.10.9+incompatible h1:OJ7YkwQA+k2Oi51lmCojpjiygKpi76P7bg91b2eJxYU= github.com/docker/cli v20.10.9+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= @@ -437,7 +453,11 @@ github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zV github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt v3.2.1+incompatible h1:73Z+4BJcrTC+KczS6WvTPvRGOp1WmfEP4Q1lOd9Z/+c= +github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= +github.com/golang-jwt/jwt/v4 v4.2.0 h1:besgBTC8w8HjP6NzQdxwKH9Z5oQMZ24ThTrHp3cZ8eU= +github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/glog v1.0.0/go.mod h1:EWib/APOK0SL3dFbYqvxE3UYd8E6s1ouQ7iEp/0LWV4= @@ -786,8 +806,10 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lN github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8= github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= +github.com/montanaflynn/stats v0.6.6/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/mrunalp/fileutils v0.5.0/go.mod h1:M1WthSahJixYnrXQl/DFQuteStB1weuxD2QJNHXfbSQ= @@ -848,6 +870,8 @@ github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+v github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= github.com/phayes/freeport v0.0.0-20180830031419-95f893ade6f2 h1:JhzVVoYvbOACxoUmOs6V/G4D5nPVUW73rKvXxP4XUJc= github.com/phayes/freeport v0.0.0-20180830031419-95f893ade6f2/go.mod h1:iIss55rKnNBTvrwdmkUpLnDpZoAHvWaiq5+iMmen4AE= +github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4 h1:Qj1ukM4GlMWXNdMBuXcXfz/Kw9s1qm0CLY32QxuSImI= +github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4/go.mod h1:N6UoU20jOqggOuDwUaBQpluzLNDqif3kq9z2wpdYEfQ= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -1098,6 +1122,7 @@ golang.org/x/crypto v0.0.0-20200414173820-0848c9571904/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/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-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20201216223049-8b5274cf687f/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= @@ -1181,6 +1206,7 @@ golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/ golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= @@ -1197,9 +1223,11 @@ golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +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-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210825183410-e898025ed96a/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211209124913-491a49abca63/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211215060638-4ddde0e984e9/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= @@ -1336,6 +1364,7 @@ golang.org/x/sys v0.0.0-20210906170528-6f6e22806c34/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211029165221-6e7872819dc8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211116061358-0a5406a5449c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/pkg/azure/blob.go b/pkg/azure/blob.go new file mode 100644 index 00000000..7ef27865 --- /dev/null +++ b/pkg/azure/blob.go @@ -0,0 +1,312 @@ +/* +Copyright 2022 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package azure + +import ( + "context" + "crypto/md5" + "errors" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + _ "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob" + corev1 "k8s.io/api/core/v1" + ctrl "sigs.k8s.io/controller-runtime" + + sourcev1 "github.com/fluxcd/source-controller/api/v1beta2" +) + +var ( + // ErrorDirectoryExists is an error returned when the filename provided + // is a directory. + ErrorDirectoryExists = errors.New("filename is a directory") +) + +const ( + resourceIDField = "resourceId" + clientIDField = "clientId" + tenantIDField = "tenantId" + clientSecretField = "clientSecret" + accountKeyField = "accountKey" +) + +// BlobClient is a minimal Azure Blob client for fetching objects. +type BlobClient struct { + azblob.ServiceClient +} + +// NewClient creates a new Azure Blob storage client. +// The credential config on the client is set based on the data from the +// Bucket and Secret. It detects credentials in the Secret in the following +// order: +// +// - azidentity.ManagedIdentityCredential for a Resource ID, when a +// resourceIDField is found. +// - azidentity.ManagedIdentityCredential for a User ID, when a clientIDField +// but no tenantIDField found. +// - azidentity.ClientSecretCredential when a tenantIDField, clientIDField and +// clientSecretField are found. +// - azblob.SharedKeyCredential when an accountKeyField is found. The Account +// Name is extracted from the endpoint specified on the Bucket object. +// +// If no credentials are found, a simple client without credentials is +// returned. +func NewClient(obj *sourcev1.Bucket, secret *corev1.Secret) (c *BlobClient, err error) { + c = &BlobClient{} + + // Without a Secret, we can return a simple client. + if secret == nil || len(secret.Data) == 0 { + c.ServiceClient, err = azblob.NewServiceClientWithNoCredential(obj.Spec.Endpoint, nil) + return + } + + // Attempt AAD Token Credential options first. + var token azcore.TokenCredential + if token, err = tokenCredentialFromSecret(secret); err != nil { + return + } + if token != nil { + c.ServiceClient, err = azblob.NewServiceClient(obj.Spec.Endpoint, token, nil) + return + } + + // Fallback to Shared Key Credential. + cred, err := sharedCredentialFromSecret(obj.Spec.Endpoint, secret) + if err != nil { + return + } + if cred != nil { + c.ServiceClient, err = azblob.NewServiceClientWithSharedKey(obj.Spec.Endpoint, cred, &azblob.ClientOptions{}) + return + } + + // Secret does not contain a valid set of credentials, fallback to simple client. + c.ServiceClient, err = azblob.NewServiceClientWithNoCredential(obj.Spec.Endpoint, nil) + return +} + +// ValidateSecret validates if the provided Secret does at least have one valid +// set of credentials. The provided Secret may be nil. +func ValidateSecret(secret *corev1.Secret) error { + if secret == nil { + return nil + } + + var valid bool + if _, hasTenantID := secret.Data[tenantIDField]; hasTenantID { + if _, hasClientID := secret.Data[clientIDField]; hasClientID { + if _, hasClientSecret := secret.Data[clientSecretField]; hasClientSecret { + valid = true + } + } + } + if _, hasResourceID := secret.Data[resourceIDField]; hasResourceID { + valid = true + } + if _, hasClientID := secret.Data[clientIDField]; hasClientID { + valid = true + } + if _, hasAccountKey := secret.Data[accountKeyField]; hasAccountKey { + valid = true + } + + if !valid { + return fmt.Errorf("invalid '%s' secret data: requires a '%s', '%s', or '%s' field, or a combination of '%s', '%s' and '%s'", + secret.Name, resourceIDField, clientIDField, accountKeyField, tenantIDField, clientIDField, clientSecretField) + } + return nil +} + +// BucketExists returns if an object storage bucket with the provided name +// exists, or returns a (client) error. +func (c *BlobClient) BucketExists(ctx context.Context, bucketName string) (bool, error) { + container := c.ServiceClient.NewContainerClient(bucketName) + _, err := container.GetProperties(ctx, nil) + if err != nil { + var stgErr *azblob.StorageError + if errors.As(err, &stgErr) { + if stgErr.ErrorCode == azblob.StorageErrorCodeContainerNotFound { + return false, nil + } + err = stgErr + } + return false, err + } + return true, nil +} + +// FGetObject gets the object from the provided object storage bucket, and +// writes it to targetPath. +// It returns the etag of the successfully fetched file, or any error. +func (c *BlobClient) FGetObject(ctx context.Context, bucketName, objectName, localPath string) (string, error) { + container := c.ServiceClient.NewContainerClient(bucketName) + blob := container.NewBlobClient(objectName) + + // Verify if destination already exists. + dirStatus, err := os.Stat(localPath) + if err == nil { + // If the destination exists and is a directory. + if dirStatus.IsDir() { + return "", ErrorDirectoryExists + } + } + + // Proceed if file does not exist, return for all other errors. + if err != nil { + if !os.IsNotExist(err) { + return "", err + } + } + + // Extract top level directory. + objectDir, _ := filepath.Split(localPath) + if objectDir != "" { + // Create any missing top level directories. + if err := os.MkdirAll(objectDir, 0700); err != nil { + return "", err + } + } + + // Download object. + res, err := blob.Download(ctx, nil) + if err != nil { + return "", err + } + + // Prepare target file. + f, err := os.OpenFile(localPath, os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return "", err + } + + // Calculate hash during write. + // NOTE: not actively used at present, as MD5 is not consistently returned + // by API. + hash := md5.New() + + // Off we go. + mw := io.MultiWriter(f, hash) + if _, err = io.Copy(mw, res.Body(nil)); err != nil { + if err = f.Close(); err != nil { + ctrl.LoggerFrom(ctx).Error(err, "failed to close file after copy error") + } + return "", err + } + if err = f.Close(); err != nil { + return "", err + } + return *res.ETag, nil +} + +// VisitObjects iterates over the items in the provided object storage +// bucket, calling visit for every item. +// If the underlying client or the visit callback returns an error, +// it returns early. +func (c *BlobClient) VisitObjects(ctx context.Context, bucketName string, visit func(path, etag string) error) error { + container := c.ServiceClient.NewContainerClient(bucketName) + + items := container.ListBlobsFlat(&azblob.ContainerListBlobFlatSegmentOptions{}) + for items.NextPage(ctx) { + resp := items.PageResponse() + + for _, blob := range resp.ContainerListBlobFlatSegmentResult.Segment.BlobItems { + if err := visit(*blob.Name, fmt.Sprintf("%x", *blob.Properties.Etag)); err != nil { + err = fmt.Errorf("listing objects from bucket '%s' failed: %w", bucketName, err) + return err + } + } + } + if err := items.Err(); err != nil { + err = fmt.Errorf("listing objects from bucket '%s' failed: %w", bucketName, err) + return err + } + return nil +} + +// Close has no effect on BlobClient. +func (c *BlobClient) Close(_ context.Context) { + return +} + +// ObjectIsNotFound checks if the error provided is an azblob.StorageError with +// an azblob.StorageErrorCodeBlobNotFound error code. +func (c *BlobClient) ObjectIsNotFound(err error) bool { + var stgErr *azblob.StorageError + if errors.As(err, &stgErr) { + if stgErr.ErrorCode == azblob.StorageErrorCodeBlobNotFound { + return true + } + } + return false +} + +func tokenCredentialFromSecret(secret *corev1.Secret) (azcore.TokenCredential, error) { + var token azcore.TokenCredential + if resourceID, ok := secret.Data[resourceIDField]; ok { + return azidentity.NewManagedIdentityCredential(&azidentity.ManagedIdentityCredentialOptions{ + ID: azidentity.ResourceID(resourceID), + }) + } + if clientID, hasClientID := secret.Data[clientIDField]; hasClientID { + tenantID, hasTenantID := secret.Data[tenantIDField] + if !hasTenantID { + return azidentity.NewManagedIdentityCredential(&azidentity.ManagedIdentityCredentialOptions{ + ID: azidentity.ClientID(clientID), + }) + } + if clientSecret, hasClientSecret := secret.Data[clientSecretField]; hasClientSecret { + return azidentity.NewClientSecretCredential(string(tenantID), string(clientID), string(clientSecret), nil) + } + } + return token, nil +} + +func sharedCredentialFromSecret(endpoint string, secret *corev1.Secret) (*azblob.SharedKeyCredential, error) { + if accountKey, hasAccountKey := secret.Data[accountKeyField]; hasAccountKey { + accountName, err := extractAccountNameFromEndpoint(endpoint) + if err != nil { + return nil, fmt.Errorf("failed to create shared credential from '%s' Secret data: %w", secret.Name, err) + } + return azblob.NewSharedKeyCredential(accountName, string(accountKey)) + } + return nil, nil +} + +// extractAccountNameFromEndpoint extracts the Azure account name from the +// provided endpoint URL. It parses the endpoint as a URL, and returns the +// first subdomain as the assumed account name. +// It returns an error when it fails to parse the endpoint as a URL, or if it +// does not have any subdomains. +func extractAccountNameFromEndpoint(endpoint string) (string, error) { + u, err := url.Parse(endpoint) + if err != nil { + return "", fmt.Errorf("failed to extract account name from endpoint: %w", err) + } + hostname := u.Hostname() + parts := strings.Split(hostname, ".") + if len(parts) <= 2 { + return "", fmt.Errorf("failed to extract account name from endpoint: expected '%s' to be a subdomain", hostname) + } + return parts[0], nil +} diff --git a/pkg/azure/blob_integration_test.go b/pkg/azure/blob_integration_test.go new file mode 100644 index 00000000..08c3ef7a --- /dev/null +++ b/pkg/azure/blob_integration_test.go @@ -0,0 +1,319 @@ +//go:build integration + +/* +Copyright 2022 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package azure + +import ( + "context" + "crypto/md5" + "encoding/hex" + "errors" + "fmt" + "math/rand" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/streaming" + "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + + sourcev1 "github.com/fluxcd/source-controller/api/v1beta2" +) + +var ( + testTimeout = time.Second * 5 +) + +var ( + testAccountName = os.Getenv("TEST_AZURE_ACCOUNT_NAME") + testAccountKey = os.Getenv("TEST_AZURE_ACCOUNT_KEY") +) + +var ( + testContainerGenerateName = "azure-client-test-" + testFile = "test.yaml" + testFileData = ` +--- +test: file +` + testFile2 = "test2.yaml" + testFile2Data = ` +--- +test: file2 +` + testBucket = sourcev1.Bucket{ + Spec: sourcev1.BucketSpec{ + Endpoint: endpointURL(testAccountName), + }, + } + testSecret = corev1.Secret{ + Data: map[string][]byte{ + accountKeyField: []byte(testAccountKey), + }, + } +) + +func init() { + rand.Seed(time.Now().UnixNano()) +} + +func TestMain(m *testing.M) { + code := m.Run() + os.Exit(code) +} + +func TestBlobClient_BucketExists(t *testing.T) { + g := NewWithT(t) + + client, err := NewClient(testBucket.DeepCopy(), testSecret.DeepCopy()) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(client).ToNot(BeNil()) + + // Generate test container name. + testContainer := generateString(testContainerGenerateName) + + // Create test container. + ctx, timeout := context.WithTimeout(context.Background(), testTimeout) + defer timeout() + g.Expect(createContainer(ctx, client, testContainer)).To(Succeed()) + t.Cleanup(func() { + g.Expect(deleteContainer(context.Background(), client, testContainer)).To(Succeed()) + }) + + // Test if the container exists. + ctx, timeout = context.WithTimeout(context.Background(), testTimeout) + defer timeout() + ok, err := client.BucketExists(ctx, testContainer) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(ok).To(BeTrue()) +} + +func TestBlobClient_BucketNotExists(t *testing.T) { + g := NewWithT(t) + + client, err := NewClient(testBucket.DeepCopy(), testSecret.DeepCopy()) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(client).ToNot(BeNil()) + + // Generate test container name. + testContainer := generateString(testContainerGenerateName) + + // Test if the container exists. + ctx, timeout := context.WithTimeout(context.Background(), testTimeout) + defer timeout() + ok, err := client.BucketExists(ctx, testContainer) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(ok).To(BeFalse()) +} + +func TestBlobClient_FGetObject(t *testing.T) { + g := NewWithT(t) + + tempDir := t.TempDir() + + client, err := NewClient(testBucket.DeepCopy(), testSecret.DeepCopy()) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(client).ToNot(BeNil()) + + // Generate test container name. + testContainer := generateString(testContainerGenerateName) + + // Create test container. + ctx, timeout := context.WithTimeout(context.Background(), testTimeout) + defer timeout() + g.Expect(createContainer(ctx, client, testContainer)).To(Succeed()) + t.Cleanup(func() { + g.Expect(deleteContainer(context.Background(), client, testContainer)).To(Succeed()) + }) + + // Create test blob. + ctx, timeout = context.WithTimeout(context.Background(), testTimeout) + defer timeout() + g.Expect(createBlob(ctx, client, testContainer, testFile, testFileData)) + + localPath := filepath.Join(tempDir, testFile) + + // Test if blob exists. + ctx, timeout = context.WithTimeout(context.Background(), testTimeout) + defer timeout() + _, err = client.FGetObject(ctx, testContainer, testFile, localPath) + + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(localPath).To(BeARegularFile()) + f, _ := os.ReadFile(localPath) + g.Expect(f).To(Equal([]byte(testFileData))) +} + +func TestBlobClient_FGetObject_NotFoundErr(t *testing.T) { + g := NewWithT(t) + + client, err := NewClient(testBucket.DeepCopy(), testSecret.DeepCopy()) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(client).ToNot(BeNil()) + + // Generate test container name. + testContainer := generateString(testContainerGenerateName) + + // Create test container. + ctx, timeout := context.WithTimeout(context.Background(), testTimeout) + defer timeout() + g.Expect(createContainer(ctx, client, testContainer)).To(Succeed()) + t.Cleanup(func() { + g.Expect(deleteContainer(context.Background(), client, testContainer)).To(Succeed()) + }) + + // Test blob does not exist. + ctx, timeout = context.WithTimeout(context.Background(), testTimeout) + defer timeout() + _, err = client.FGetObject(ctx, testContainer, "doesnotexist.txt", "doesnotexist.txt") + + g.Expect(err).To(HaveOccurred()) + g.Expect(client.ObjectIsNotFound(err)).To(BeTrue()) +} + +func TestBlobClient_VisitObjects(t *testing.T) { + g := NewWithT(t) + + client, err := NewClient(testBucket.DeepCopy(), testSecret.DeepCopy()) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(client).ToNot(BeNil()) + + // Generate test container name. + testContainer := generateString(testContainerGenerateName) + + // Create test container. + ctx, timeout := context.WithTimeout(context.Background(), testTimeout) + defer timeout() + g.Expect(createContainer(ctx, client, testContainer)).To(Succeed()) + t.Cleanup(func() { + g.Expect(deleteContainer(context.Background(), client, testContainer)).To(Succeed()) + }) + + // Create test blobs. + ctx, timeout = context.WithTimeout(context.Background(), testTimeout) + defer timeout() + g.Expect(createBlob(ctx, client, testContainer, testFile, testFileData)) + g.Expect(createBlob(ctx, client, testContainer, testFile2, testFile2Data)) + + visits := make(map[string]string) + + // Visit objects. + ctx, timeout = context.WithTimeout(context.Background(), testTimeout) + defer timeout() + got := client.VisitObjects(ctx, testContainer, func(path, etag string) error { + visits[path] = etag + return nil + }) + + g.Expect(got).To(Succeed()) + g.Expect(visits[testFile]).ToNot(BeEmpty()) + g.Expect(visits[testFile2]).ToNot(BeEmpty()) + g.Expect(visits[testFile]).ToNot(Equal(visits[testFile2])) +} + +func TestBlobClient_VisitObjects_CallbackErr(t *testing.T) { + g := NewWithT(t) + + client, err := NewClient(testBucket.DeepCopy(), testSecret.DeepCopy()) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(client).ToNot(BeNil()) + + // Generate test container name. + testContainer := generateString(testContainerGenerateName) + + // Create test container. + ctx, timeout := context.WithTimeout(context.Background(), testTimeout) + defer timeout() + g.Expect(createContainer(ctx, client, testContainer)).To(Succeed()) + t.Cleanup(func() { + g.Expect(deleteContainer(context.Background(), client, testContainer)).To(Succeed()) + }) + + // Create test blob. + ctx, timeout = context.WithTimeout(context.Background(), testTimeout) + defer timeout() + g.Expect(createBlob(ctx, client, testContainer, testFile, testFileData)) + + // Visit object. + ctx, timeout = context.WithTimeout(context.Background(), testTimeout) + defer timeout() + mockErr := fmt.Errorf("mock") + err = client.VisitObjects(ctx, testContainer, func(path, etag string) error { + return mockErr + }) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("mock")) +} + +func createContainer(ctx context.Context, client *BlobClient, name string) error { + if _, err := client.CreateContainer(ctx, name, nil); err != nil { + var stgErr *azblob.StorageError + if errors.As(err, &stgErr) { + if stgErr.ErrorCode == azblob.StorageErrorCodeContainerAlreadyExists { + return nil + } + err = stgErr + } + return err + } + return nil +} + +func createBlob(ctx context.Context, client *BlobClient, containerName, name, data string) error { + container := client.NewContainerClient(containerName) + blob := container.NewAppendBlobClient(name) + + ctx, timeout := context.WithTimeout(context.Background(), testTimeout) + defer timeout() + if _, err := blob.Create(ctx, nil); err != nil { + return err + } + + hash := md5.Sum([]byte(data)) + if _, err := blob.AppendBlock(ctx, streaming.NopCloser(strings.NewReader(data)), &azblob.AppendBlockOptions{ + TransactionalContentMD5: hash[:16], + }); err != nil { + return err + } + return nil +} + +func deleteContainer(ctx context.Context, client *BlobClient, name string) error { + if _, err := client.DeleteContainer(ctx, name, nil); err != nil { + var stgErr *azblob.StorageError + if errors.As(err, &stgErr) { + if code := stgErr.ErrorCode; code == azblob.StorageErrorCodeContainerNotFound || + code == azblob.StorageErrorCodeContainerBeingDeleted { + return nil + } + err = stgErr + } + return err + } + return nil +} + +func generateString(prefix string) string { + randBytes := make([]byte, 16) + rand.Read(randBytes) + return prefix + hex.EncodeToString(randBytes) +} diff --git a/pkg/azure/blob_test.go b/pkg/azure/blob_test.go new file mode 100644 index 00000000..3dd59156 --- /dev/null +++ b/pkg/azure/blob_test.go @@ -0,0 +1,318 @@ +/* +Copyright 2022 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package azure + +import ( + "errors" + "fmt" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" +) + +func TestValidateSecret(t *testing.T) { + tests := []struct { + name string + secret *corev1.Secret + wantErr bool + }{ + { + name: "valid SystemManagedIdentity Secret", + secret: &corev1.Secret{ + Data: map[string][]byte{ + resourceIDField: []byte("/some/resource/id"), + }, + }, + }, + { + name: "valid UserManagerIdentity Secret", + secret: &corev1.Secret{ + Data: map[string][]byte{ + clientIDField: []byte("some-client-id-"), + }, + }, + }, + { + name: "valid ServicePrincipal Secret", + secret: &corev1.Secret{ + Data: map[string][]byte{ + tenantIDField: []byte("some-tenant-id-"), + clientIDField: []byte("some-client-id-"), + clientSecretField: []byte("some-client-secret-"), + }, + }, + }, + { + name: "valid SharedKey Secret", + secret: &corev1.Secret{ + Data: map[string][]byte{ + accountKeyField: []byte("some-account-key"), + }, + }, + }, + { + name: "invalid ServicePrincipal Secret with missing ClientID and ClientSecret", + secret: &corev1.Secret{ + Data: map[string][]byte{ + tenantIDField: []byte("some-tenant-id-"), + }, + }, + wantErr: true, + }, + { + name: "invalid empty secret", + secret: &corev1.Secret{}, + wantErr: true, + }, + { + name: "valid nil secret", + secret: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + want := BeNil() + if tt.wantErr { + want = HaveOccurred() + } + g.Expect(ValidateSecret(tt.secret)).To(want) + }) + } +} + +func TestBlobClient_ObjectIsNotFound(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + { + name: "StorageError with BlobNotFound code", + err: &azblob.StorageError{ErrorCode: azblob.StorageErrorCodeBlobNotFound}, + want: true, + }, + { + name: "StorageError with different code", + err: &azblob.StorageError{ErrorCode: azblob.StorageErrorCodeInternalError}, + }, + { + name: "other error", + err: errors.New("an error"), + }, + { + name: "nil error", + err: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + c := &BlobClient{} + g.Expect(c.ObjectIsNotFound(tt.err)).To(Equal(tt.want)) + }) + } +} + +func Test_extractAccountNameFromEndpoint(t *testing.T) { + tests := []struct { + name string + endpoint string + want string + wantErr bool + }{ + { + name: "returns account name for endpoint", + endpoint: "https://foo.blob.core.windows.net", + want: "foo", + }, + { + name: "error for endpoint URL parse err", + endpoint: "#http//foo.blob.core.windows.net", + wantErr: true, + }, + { + name: "error for endpoint URL without subdomain", + endpoint: "https://windows.net", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + got, err := extractAccountNameFromEndpoint(tt.endpoint) + g.Expect(err != nil).To(Equal(tt.wantErr)) + g.Expect(got).To(Equal(tt.want)) + }) + } +} + +func Test_tokenCredentialFromSecret(t *testing.T) { + tests := []struct { + name string + secret *corev1.Secret + want azcore.TokenCredential + wantErr bool + }{ + { + name: "with ResourceID field", + secret: &corev1.Secret{ + Data: map[string][]byte{ + resourceIDField: []byte("resource-id"), + }, + }, + want: &azidentity.ManagedIdentityCredential{}, + }, + { + name: "with ClientID field", + secret: &corev1.Secret{ + Data: map[string][]byte{ + clientIDField: []byte("client-id"), + }, + }, + want: &azidentity.ManagedIdentityCredential{}, + }, + { + name: "with TenantID, ClientID and ClientSecret fields", + secret: &corev1.Secret{ + Data: map[string][]byte{ + clientIDField: []byte("client-id"), + tenantIDField: []byte("tenant-id"), + clientSecretField: []byte("client-secret"), + }, + }, + want: &azidentity.ClientSecretCredential{}, + }, + { + name: "empty secret", + secret: &corev1.Secret{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + got, err := tokenCredentialFromSecret(tt.secret) + g.Expect(err != nil).To(Equal(tt.wantErr)) + if tt.want != nil { + g.Expect(got).ToNot(BeNil()) + g.Expect(got).To(BeAssignableToTypeOf(tt.want)) + return + } + g.Expect(got).To(BeNil()) + }) + } +} + +func Test_sharedCredentialFromSecret(t *testing.T) { + var testKey = []byte("dGVzdA==") + tests := []struct { + name string + endpoint string + secret *corev1.Secret + want *azblob.SharedKeyCredential + wantErr bool + }{ + { + name: "with AccountKey field", + endpoint: "https://some.endpoint.com", + secret: &corev1.Secret{ + Data: map[string][]byte{ + accountKeyField: testKey, + }, + }, + want: &azblob.SharedKeyCredential{}, + }, + { + name: "invalid endpoint", + endpoint: "#http//some.endpoint.com", + secret: &corev1.Secret{ + Data: map[string][]byte{ + accountKeyField: testKey, + }, + }, + wantErr: true, + }, + { + name: "empty secret", + secret: &corev1.Secret{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + got, err := sharedCredentialFromSecret(tt.endpoint, tt.secret) + g.Expect(err != nil).To(Equal(tt.wantErr)) + if tt.want != nil { + g.Expect(got).ToNot(BeNil()) + g.Expect(got).To(BeAssignableToTypeOf(tt.want)) + return + } + g.Expect(got).To(BeNil()) + }) + } +} + +func Test_extractAccountNameFromEndpoint1(t *testing.T) { + tests := []struct { + name string + endpoint string + want string + wantErr string + }{ + { + name: "valid URL", + endpoint: endpointURL("foo"), + want: "foo", + }, + { + name: "URL parse error", + endpoint: " https://example.com", + wantErr: "first path segment in URL cannot contain colon", + }, + { + name: "error on non subdomain", + endpoint: "https://example.com", + wantErr: "expected 'example.com' to be a subdomain", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + got, err := extractAccountNameFromEndpoint(tt.endpoint) + if tt.wantErr != "" { + g.Expect(err.Error()).To(ContainSubstring(tt.wantErr)) + g.Expect(got).To(BeEmpty()) + return + } + g.Expect(err).To(BeNil()) + g.Expect(got).To(Equal(tt.want)) + }) + } +} + +func endpointURL(accountName string) string { + return fmt.Sprintf("https://%s.blob.core.windows.net", accountName) +}