Repository: serverless/serverless-azure-functions
Pull request #177 (2019-06-21 23:39:17)

feat: Azure Blob Storage service
Wrapper service for Azure Blob Storage functionality. To be used in update of deployment process for rollback capability.

Resolves [AB#361]

mydiemho: @tbarlow12 please also add to description on what this PR is about

Patch:
diff --git a/package-lock.json b/package-lock.json
index b3cfd58..e47f444 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -73,6 +73,31 @@
         "adal-node": "^0.1.28"
       }
     },
+    "@azure/storage-blob": {
+      "version": "10.3.0",
+      "resolved": "https://registry.npmjs.org/@azure/storage-blob/-/storage-blob-10.3.0.tgz",
+      "integrity": "sha512-KZbJ3q8RpAdeIB5Em1lgXkiq7Mll9bSHHbHavOFMepkkF7HQa3Sez9FdkAVIkVVWK5YoBlshBGZ+mtiSQiS9Fw==",
+      "requires": {
+        "@azure/ms-rest-js": "1.2.3",
+        "events": "3.0.0",
+        "tslib": "^1.9.3"
+      },
+      "dependencies": {
+        "@azure/ms-rest-js": {
+          "version": "1.2.3",
+          "resolved": "https://registry.npmjs.org/@azure/ms-rest-js/-/ms-rest-js-1.2.3.tgz",
+          "integrity": "sha512-eROQ034b+9v0Hd3wETKi/EwF5pqS3VRAk1Lm8iKVPOP8v30f6Zfzsi420MRfBMsbNCx/mE2N0L65Px7tvcGfVg==",
+          "requires": {
+            "axios": "^0.18.0",
+            "form-data": "^2.3.2",
+            "tough-cookie": "^2.4.3",
+            "tslib": "^1.9.2",
+            "uuid": "^3.2.1",
+            "xml2js": "^0.4.19"
+          }
+        }
+      }
+    },
     "@babel/code-frame": {
       "version": "7.0.0",
       "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.0.0.tgz",
@@ -1759,7 +1784,6 @@
       "version": "0.1.4",
       "resolved": "https://registry.npmjs.org/align-text/-/align-text-0.1.4.tgz",
       "integrity": "sha1-DNkKVhCT810KmSVsIrcGlDP60Rc=",
-      "optional": true,
       "requires": {
         "kind-of": "^3.0.2",
         "longest": "^1.0.1",
@@ -1932,7 +1956,6 @@
       "version": "4.10.1",
       "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.10.1.tgz",
       "integrity": "sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw==",
-      "optional": true,
       "requires": {
         "bn.js": "^4.0.0",
         "inherits": "^2.0.1",
@@ -2402,8 +2425,7 @@
     "bn.js": {
       "version": "4.11.8",
       "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz",
-      "integrity": "sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA==",
-      "optional": true
+      "integrity": "sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA=="
     },
     "body-parser": {
       "version": "1.19.0",
@@ -2508,8 +2530,7 @@
     "brorand": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz",
-      "integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=",
-      "optional": true
+      "integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8="
     },
     "browser-process-hrtime": {
       "version": "0.1.3",
@@ -2538,7 +2559,6 @@
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz",
       "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==",
-      "optional": true,
       "requires": {
         "buffer-xor": "^1.0.3",
         "cipher-base": "^1.0.0",
@@ -2575,7 +2595,6 @@
       "version": "4.0.1",
       "resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.0.1.tgz",
       "integrity": "sha1-IeCr+vbyApzy+vsTNWenAdQTVSQ=",
-      "optional": true,
       "requires": {
         "bn.js": "^4.1.0",
         "randombytes": "^2.0.1"
@@ -2672,8 +2691,7 @@
     "buffer-xor": {
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz",
-      "integrity": "sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk=",
-      "optional": true
+      "integrity": "sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk="
     },
     "builtin-modules": {
       "version": "1.1.1",
@@ -2855,7 +2873,6 @@
       "version": "1.0.4",
       "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz",
       "integrity": "sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==",
-      "optional": true,
       "requires": {
         "inherits": "^2.0.1",
         "safe-buffer": "^5.0.1"
@@ -3233,7 +3250,6 @@
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz",
       "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==",
-      "optional": true,
       "requires": {
         "cipher-base": "^1.0.1",
         "inherits": "^2.0.1",
@@ -3246,7 +3262,6 @@
       "version": "1.1.7",
       "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz",
       "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==",
-      "optional": true,
       "requires": {
         "cipher-base": "^1.0.3",
         "create-hash": "^1.1.0",
@@ -3705,7 +3720,6 @@
       "version": "6.4.1",
       "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.4.1.tgz",
       "integrity": "sha512-BsXLz5sqX8OHcsh7CqBMztyXARmGQ3LWPtGjJi6DiJHq5C/qvi9P3OqgswKSDftbu8+IoI/QDTAm2fFnQ9SZSQ==",
-      "optional": true,
       "requires": {
         "bn.js": "^4.4.0",
         "brorand": "^1.0.1",
@@ -3767,7 +3781,6 @@
       "version": "0.1.7",
       "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.7.tgz",
       "integrity": "sha512-MfrRBDWzIWifgq6tJj60gkAwtLNb6sQPlcFrSOflcP1aFmmruKQ2wRnze/8V6kgyz7H3FF8Npzv78mZ7XLLflg==",
-      "optional": true,
       "requires": {
         "prr": "~1.0.1"
       }
@@ -4147,7 +4160,6 @@
       "version": "0.3.5",
       "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz",
       "integrity": "sha1-34xp7vFkeSPHFXuc6DhAYQsCzDk=",
-      "optional": true,
       "requires": {
         "d": "1",
         "es5-ext": "~0.10.14"
@@ -4156,14 +4168,12 @@
     "events": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/events/-/events-3.0.0.tgz",
-      "integrity": "sha512-Dc381HFWJzEOhQ+d8pkNon++bk9h6cdAoAj4iE6Q4y6xgTzySWXlKn05/TVNpjnfRqi/X0EpJEJohPjNI3zpVA==",
-      "optional": true
+      "integrity": "sha512-Dc381HFWJzEOhQ+d8pkNon++bk9h6cdAoAj4iE6Q4y6xgTzySWXlKn05/TVNpjnfRqi/X0EpJEJohPjNI3zpVA=="
     },
     "evp_bytestokey": {
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz",
       "integrity": "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==",
-      "optional": true,
       "requires": {
         "md5.js": "^1.3.4",
         "safe-buffer": "^5.1.1"
@@ -5461,7 +5471,6 @@
       "version": "3.0.4",
       "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.0.4.tgz",
       "integrity": "sha1-X8hoaEfs1zSZQDMZprCj8/auSRg=",
-      "optional": true,
       "requires": {
         "inherits": "^2.0.1",
         "safe-buffer": "^5.0.1"
@@ -5471,7 +5480,6 @@
       "version": "1.1.7",
       "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
       "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
-      "optional": true,
       "requires": {
         "inherits": "^2.0.3",
         "minimalistic-assert": "^1.0.1"
@@ -5481,7 +5489,6 @@
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz",
       "integrity": "sha1-0nRXAQJabHdabFRXk+1QL8DGSaE=",
-      "optional": true,
       "requires": {
         "hash.js": "^1.0.3",
         "minimalistic-assert": "^1.0.0",
@@ -5800,8 +5807,7 @@
     "is-extglob": {
       "version": "2.1.1",
       "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
-      "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=",
-      "optional": true
+      "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI="
     },
     "is-fullwidth-code-point": {
       "version": "1.0.0",
@@ -7441,8 +7447,7 @@
     "longest": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz",
-      "integrity": "sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc=",
-      "optional": true
+      "integrity": "sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc="
     },
     "loose-envify": {
       "version": "1.4.0",
@@ -7533,7 +7538,6 @@
       "version": "1.3.5",
       "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz",
       "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==",
-      "optional": true,
       "requires": {
         "hash-base": "^3.0.0",
         "inherits": "^2.0.1",
@@ -7559,7 +7563,6 @@
       "version": "0.4.1",
       "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.4.1.tgz",
       "integrity": "sha1-OpoguEYlI+RHz7x+i7gO1me/xVI=",
-      "optional": true,
       "requires": {
         "errno": "^0.1.3",
         "readable-stream": "^2.0.1"
@@ -7650,14 +7653,12 @@
     "minimalistic-assert": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
-      "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
-      "optional": true
+      "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="
     },
     "minimalistic-crypto-utils": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz",
-      "integrity": "sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=",
-      "optional": true
+      "integrity": "sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo="
     },
     "minimatch": {
       "version": "3.0.4",
@@ -8255,7 +8256,6 @@
       "version": "5.1.4",
       "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.4.tgz",
       "integrity": "sha512-Qs5duJcuvNExRfFZ99HDD3z4mAi3r9Wl/FOjEOijlxwCZs7E7mW2vjTpgQ4J8LpTF8x5v+1Vn5UQFejmWT11aw==",
-      "optional": true,
       "requires": {
         "asn1.js": "^4.0.0",
         "browserify-aes": "^1.0.0",
@@ -8364,7 +8364,6 @@
       "version": "3.0.17",
       "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.0.17.tgz",
       "integrity": "sha512-U/il5MsrZp7mGg3mSQfn742na2T+1/vHDCG5/iTI3X9MKUuYUZVLQhyRsg06mCgDBTd57TxzgZt7P+fYfjRLtA==",
-      "optional": true,
       "requires": {
         "create-hash": "^1.1.2",
         "create-hmac": "^1.1.4",
@@ -8567,8 +8566,7 @@
     "prr": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz",
-      "integrity": "sha1-0/wRS6BplaRexok/SEzrHXj19HY=",
-      "optional": true
+      "integrity": "sha1-0/wRS6BplaRexok/SEzrHXj19HY="
     },
     "pseudomap": {
       "version": "1.0.2",
@@ -8635,7 +8633,6 @@
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
       "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
-      "optional": true,
       "requires": {
         "safe-buffer": "^5.1.0"
       }
@@ -9048,7 +9045,6 @@
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz",
       "integrity": "sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==",
-      "optional": true,
       "requires": {
         "hash-base": "^3.0.0",
         "inherits": "^2.0.1"
@@ -9438,7 +9434,6 @@
       "version": "2.4.11",
       "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz",
       "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==",
-      "optional": true,
       "requires": {
         "inherits": "^2.0.1",
         "safe-buffer": "^5.0.1"
@@ -9634,8 +9629,7 @@
     "source-list-map": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz",
-      "integrity": "sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==",
-      "optional": true
+      "integrity": "sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw=="
     },
     "source-map": {
       "version": "0.7.3",
@@ -10192,8 +10186,7 @@
     "tapable": {
       "version": "0.2.9",
       "resolved": "https://registry.npmjs.org/tapable/-/tapable-0.2.9.tgz",
-      "integrity": "sha512-2wsvQ+4GwBvLPLWsNfLCDYGsW6xb7aeC6utq2Qh0PFwgEy7K7dsma9Jsmb2zSQj7GvYAyUGSntLtsv++GmgL1A==",
-      "optional": true
+      "integrity": "sha512-2wsvQ+4GwBvLPLWsNfLCDYGsW6xb7aeC6utq2Qh0PFwgEy7K7dsma9Jsmb2zSQj7GvYAyUGSntLtsv++GmgL1A=="
     },
     "tar-stream": {
       "version": "1.6.2",
@@ -11043,7 +11036,6 @@
       "version": "1.3.0",
       "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.3.0.tgz",
       "integrity": "sha512-OiVgSrbGu7NEnEvQJJgdSFPl2qWKkWq5lHMhgiToIiN9w34EBnjYzSYs+VbL5KoYiLNtFFa7BZIKxRED3I32pA==",
-      "optional": true,
       "requires": {
         "source-list-map": "^2.0.0",
         "source-map": "~0.6.1"
@@ -11052,8 +11044,7 @@
         "source-map": {
           "version": "0.6.1",
           "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
-          "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
-          "optional": true
+          "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
         }
       }
     },
diff --git a/package.json b/package.json
index fa52a26..80c7824 100644
--- a/package.json
+++ b/package.json
@@ -39,6 +39,7 @@
     "@azure/arm-appservice": "^5.7.0",
     "@azure/arm-resources": "^1.0.1",
     "@azure/ms-rest-nodeauth": "^1.0.1",
+    "@azure/storage-blob": "^10.3.0",
     "axios": "^0.18.0",
     "js-yaml": "^3.13.1",
     "jsonpath": "^1.0.1",
diff --git a/src/armTemplates/resources/appInsights.ts b/src/armTemplates/resources/appInsights.ts
index 9e7df06..1223062 100644
--- a/src/armTemplates/resources/appInsights.ts
+++ b/src/armTemplates/resources/appInsights.ts
@@ -1,5 +1,5 @@
 import { ArmResourceTemplateGenerator, ArmResourceTemplate } from "../../models/armTemplates";
-import { ServerlessAzureConfig, ResourceConfig } from "../../models/serverless";
+import { ServerlessAzureConfig } from "../../models/serverless";
 
 export class AppInsightsResource implements ArmResourceTemplateGenerator {
   public static getResourceName(config: ServerlessAzureConfig) {
diff --git a/src/armTemplates/resources/hostingEnvironment.ts b/src/armTemplates/resources/hostingEnvironment.ts
index f785485..0591002 100644
--- a/src/armTemplates/resources/hostingEnvironment.ts
+++ b/src/armTemplates/resources/hostingEnvironment.ts
@@ -1,5 +1,5 @@
 import { ArmResourceTemplateGenerator, ArmResourceTemplate } from "../../models/armTemplates";
-import { ServerlessAzureConfig, ResourceConfig } from "../../models/serverless";
+import { ServerlessAzureConfig } from "../../models/serverless";
 
 export class HostingEnvironmentResource implements ArmResourceTemplateGenerator {
   public static getResourceName(config: ServerlessAzureConfig) {
diff --git a/src/armTemplates/resources/virtualNetwork.ts b/src/armTemplates/resources/virtualNetwork.ts
index b93448b..9c1feff 100644
--- a/src/armTemplates/resources/virtualNetwork.ts
+++ b/src/armTemplates/resources/virtualNetwork.ts
@@ -1,5 +1,5 @@
 import { ArmResourceTemplateGenerator, ArmResourceTemplate } from "../../models/armTemplates";
-import { ServerlessAzureConfig, ResourceConfig } from "../../models/serverless";
+import { ServerlessAzureConfig } from "../../models/serverless";
 
 export class VirtualNetworkResource implements ArmResourceTemplateGenerator {
   public static getResourceName(config: ServerlessAzureConfig) {
diff --git a/src/services/azureBlobStorageService.ts b/src/services/azureBlobStorageService.ts
new file mode 100644
index 0000000..9946cea
--- /dev/null
+++ b/src/services/azureBlobStorageService.ts
@@ -0,0 +1,152 @@
+import { Aborter, BlockBlobURL, ContainerURL, ServiceURL, StorageURL, uploadFileToBlockBlob } from "@azure/storage-blob";
+import Serverless from "serverless";
+import { BaseService } from "./baseService";
+import { Guard } from "../shared/guard";
+
+/**
+ * Wrapper for operations on Azure Blob Storage account
+ */
+export class AzureBlobStorageService extends BaseService {
+
+  /**
+   * Account URL for Azure Blob Storage account. Depends on `storageAccountName` being set in baseService
+   */
+  private accountUrl: string;
+
+  public constructor(serverless: Serverless, options: Serverless.Options) {
+    super(serverless, options);
+    this.accountUrl = `https://${this.storageAccountName}.blob.core.windows.net`;
+  }
+
+  /**
+   * Upload a file to Azure Blob Storage
+   * @param path Path of file to upload
+   * @param containerName Name of container in Azure Blob storage for upload
+   * @param blobName Name of blob file created as a result of upload
+   */
+  public async uploadFile(path: string, containerName: string, blobName?: string) {
+    Guard.empty(path);
+    Guard.empty(containerName);
+    // Use specified blob name or replace `/` in path with `-`
+    const name = blobName || path.replace(/^.*[\\\/]/, "-");
+    uploadFileToBlockBlob(Aborter.none, path, this.getBlockBlobURL(containerName, name));
+  };
+  
+  /**
+   * Delete a blob from Azure Blob Storage
+   * @param containerName Name of container containing blob
+   * @param blobName Blob to delete
+   */
+  public async deleteFile(containerName: string, blobName: string): Promise<void> {
+    Guard.empty(containerName);
+    Guard.empty(blobName);
+    const blockBlobUrl = await this.getBlockBlobURL(containerName, blobName)
+    await blockBlobUrl.delete(Aborter.none);
+  }
+
+  /**
+   * Lists files in container
+   * @param ext - Extension of files to filter on when retrieving files
+   * from container
+   */
+  public async listFiles(containerName: string, ext?: string): Promise<string[]> {
+    Guard.empty(containerName, "containerName");
+    const result: string[] = [];
+    let marker;
+    const containerURL = this.getContainerURL(containerName);
+    do {
+      const listBlobsResponse = await containerURL.listBlobFlatSegment(
+        Aborter.none,
+        marker,
+      );
+      marker = listBlobsResponse.nextMarker;
+      for (const blob of listBlobsResponse.segment.blobItems) {
+        if ((ext && blob.name.endsWith(ext)) || !ext) {
+          result.push(blob.name);
+        }
+      }
+    } while (marker);
+
+    return result;
+  }
+
+  /**
+   * Lists the containers within the Azure Blob Storage account
+   */
+  public async listContainers() {
+    const result: string[] = [];
+    let marker;
+    do {
+      const listContainersResponse = await this.getServiceURL().listContainersSegment(
+        Aborter.none,
+        marker,
+      );
+      marker = listContainersResponse.nextMarker;
+      for (const container of listContainersResponse.containerItems) {
+        result.push(container.name);
+      }
+    } while (marker);
+
+    return result;
+  }
+
+  /**
+   * Creates container specified in Azure Cloud Storage options
+   * @param containerName - Name of container to create
+   */
+  public async createContainer(containerName: string): Promise<void> {
+    Guard.empty(containerName);
+    const containerURL = this.getContainerURL(containerName);
+    await containerURL.create(Aborter.none);
+  }
+
+  /**
+   * Delete a container from Azure Blob Storage Account
+   * @param containerName Name of container to delete
+   */
+  public async deleteContainer(containerName: string): Promise<void> {
+    Guard.empty(containerName);
+    const containerUrl = await this.getContainerURL(containerName)
+    await containerUrl.delete(Aborter.none);
+  }
+
+  /**
+   * Get ServiceURL object for Azure Blob Storage Account
+   */
+  private getServiceURL(): ServiceURL {
+    const pipeline = StorageURL.newPipeline(this.credentials);
+    const accountUrl = this.accountUrl;
+    const serviceUrl = new ServiceURL(
+      accountUrl,
+      pipeline,
+    );
+    return serviceUrl;
+  }
+
+  /**
+   * Get a ContainerURL object to perform operations on Azure Blob Storage container
+   * @param containerName Name of container
+   * @param serviceURL Previously created ServiceURL object (will create if undefined)
+   */
+  private getContainerURL(containerName: string): ContainerURL {
+    Guard.empty(containerName);
+    return ContainerURL.fromServiceURL(
+      this.getServiceURL(),
+      containerName
+    );
+  }
+
+  /**
+   * Get a BlockBlobURL object to perform operations on Azure Blob Storage Blob
+   * @param containerName Name of container containing blob
+   * @param blobName Name of blob
+   */
+  private getBlockBlobURL(containerName: string, blobName: string): BlockBlobURL {
+    Guard.empty(containerName);
+    Guard.empty(blobName);
+    return BlockBlobURL.fromContainerURL(
+      this.getContainerURL(containerName),
+      blobName,
+    );
+  }
+}
diff --git a/src/services/baseService.ts b/src/services/baseService.ts
index 44b4231..c45a86a 100644
--- a/src/services/baseService.ts
+++ b/src/services/baseService.ts
@@ -12,6 +12,8 @@ export abstract class BaseService {
   protected subscriptionId: string;
   protected resourceGroup: string;
   protected deploymentName: string;
+  protected deploymentContainerName: string;
+  protected storageAccountName: string;
   protected config: ServerlessAzureConfig;
 
   protected constructor(
diff --git a/src/services/functionAppService.ts b/src/services/functionAppService.ts
index b4d26f6..e83fd6d 100644
--- a/src/services/functionAppService.ts
+++ b/src/services/functionAppService.ts
@@ -241,7 +241,7 @@ export class FunctionAppService extends BaseService {
    */
   private getScmDomain(functionApp: Site) {
     return functionApp.enabledHostNames.find((hostName: string) => {
-      return hostName.endsWith("scm.azurewebsites.net");
+      return hostName.includes(".scm.") && hostName.endsWith(".azurewebsites.net");
     });
   }
 }


Test patch:
diff --git a/src/services/azureBlobStorageService.test.ts b/src/services/azureBlobStorageService.test.ts
new file mode 100644
index 0000000..9302e14
--- /dev/null
+++ b/src/services/azureBlobStorageService.test.ts
@@ -0,0 +1,92 @@
+import { MockFactory } from "../test/mockFactory"
+import mockFs from "mock-fs";
+
+jest.mock("@azure/storage-blob");
+import { BlockBlobURL, ContainerURL, ServiceURL, Aborter, uploadFileToBlockBlob } from "@azure/storage-blob";
+import { AzureBlobStorageService } from "./azureBlobStorageService";
+
+describe("Azure Blob Storage Service", () => {
+
+  const filePath = "deployments/deployment.zip";
+  const fileName = "deployment.zip";
+  const fileContents = "contents";
+  const containerName = "DEPLOYMENTS";
+
+  const containers = MockFactory.createTestAzureContainers();
+  const sls = MockFactory.createTestServerless();
+  const options = MockFactory.createTestServerlessOptions();
+  const blockBlobUrl = MockFactory.createTestBlockBlobUrl(containerName, filePath);
+
+  let service: AzureBlobStorageService;
+
+  beforeAll(() => {
+    BlockBlobURL.fromContainerURL = jest.fn(() => blockBlobUrl) as any;
+  });
+
+  beforeAll(() => {
+    mockFs({
+      "deployments/deployment.zip": fileContents
+    })
+  });
+
+  afterAll(() => {
+    mockFs.restore();
+  });
+
+  beforeEach(() => {
+    service = new AzureBlobStorageService(sls, options);
+  });
+
+  it("should upload a file", async () => {
+    uploadFileToBlockBlob.prototype = jest.fn();
+    ContainerURL.fromServiceURL = jest.fn((serviceUrl, containerName) => (containerName as any));
+    await service.uploadFile(filePath, containerName);
+    expect(uploadFileToBlockBlob).toBeCalledWith(
+      Aborter.none,
+      filePath,
+      blockBlobUrl
+    );
+  });
+
+  it("should delete a file", async () => {
+    ContainerURL.fromServiceURL = jest.fn((serviceUrl, containerName) => (containerName as any));
+    await service.deleteFile(containerName, fileName);
+    expect(blockBlobUrl.delete).toBeCalledWith(Aborter.none)
+  });
+
+  it("should list files of container", async () => {
+    const blobs = MockFactory.createTestAzureBlobItems();
+    ContainerURL.prototype.listBlobFlatSegment = jest.fn(() => Promise.resolve(blobs)) as any;
+    ContainerURL.fromServiceURL = jest.fn(() => new ContainerURL(null, null));
+    const files = await service.listFiles(containerName);
+    expect(files.length).toEqual(5);
+
+    const otherFiles = await service.listFiles(containerName, "jpeg");
+    expect(otherFiles.length).toEqual(0);
+  });
+
+  it("should list containers", async () => {
+    ServiceURL.prototype.listContainersSegment = jest.fn(() => Promise.resolve(containers));
+    const containerList = await service.listContainers();
+    expect(containerList).toEqual(
+      containers.containerItems.map((container) => container.name));
+  });
+
+  it("should create a container", async () => {
+    ContainerURL.fromServiceURL = jest.fn(() => new ContainerURL(null, null));
+    ContainerURL.prototype.create = jest.fn(() => Promise.resolve({ statusCode: 201 })) as any;
+    const newContainerName = "newContainer";
+    await service.createContainer(newContainerName);
+    expect(ContainerURL.fromServiceURL).toBeCalledWith(expect.anything(), newContainerName);
+    expect(ContainerURL.prototype.create).toBeCalledWith(Aborter.none);
+  });
+  
+  it("should delete a container", async () => {
+    const containerToDelete = "delete container";
+    ContainerURL.fromServiceURL = jest.fn(() => new ContainerURL(null, null));
+    ContainerURL.prototype.delete = jest.fn(() => Promise.resolve({ statusCode: 204 })) as any;
+    await service.deleteContainer(containerToDelete);
+    expect(ContainerURL.fromServiceURL).toBeCalledWith(expect.anything(), containerToDelete);
+    expect(ContainerURL.prototype.delete).toBeCalledWith(Aborter.none);
+  });
+});
diff --git a/src/services/functionAppService.test.ts b/src/services/functionAppService.test.ts
index 51aa527..e6def2c 100644
--- a/src/services/functionAppService.test.ts
+++ b/src/services/functionAppService.test.ts
@@ -13,7 +13,6 @@ import { ArmDeployment, ArmTemplateType } from "../models/armTemplates";
 jest.mock("@azure/arm-resources")
 
 describe("Function App Service", () => {
-
   const app = MockFactory.createTestSite();
   const slsService = MockFactory.createTestService();
   const variables = MockFactory.createTestVariables();
@@ -31,12 +30,8 @@ describe("Function App Service", () => {
   const authKeyUrl = `${baseUrl}${app.id}/functions/admin/token?api-version=2016-08-01`;
   const syncTriggersUrl = `${baseUrl}${app.id}/syncfunctiontriggers?api-version=2016-08-01`;
   const listFunctionsUrl = `${baseUrl}${app.id}/functions?api-version=2016-08-01`;
-  const scmDomain = app.enabledHostNames.find((hostname) => hostname.endsWith("scm.azurewebsites.net"));
-  const uploadUrl = `https://${scmDomain}/api/zipdeploy/`;
 
   beforeAll(() => {
-
-    // TODO: How to spy on default exported function?
     const axiosMock = new MockAdapter(axios);
 
     // Master Key
@@ -59,7 +54,6 @@ describe("Function App Service", () => {
   });
 
   beforeEach(() => {
-
     WebSiteManagementClient.prototype.webApps = {
       get: jest.fn(() => app),
       deleteFunction: jest.fn(),
@@ -107,8 +101,8 @@ describe("Function App Service", () => {
 
   it("gets master key", async () => {
     const service = createService();
-    const masterKey = await service.getMasterKey();
-    expect(masterKey).toEqual(masterKey);
+    const result = await service.getMasterKey();
+    expect(result).toEqual(masterKey);
 
   });
 
@@ -197,11 +191,41 @@ describe("Function App Service", () => {
   });
 
   it("uploads functions", async () => {
+    const scmDomain = app.enabledHostNames.find((hostname) => hostname.endsWith("scm.azurewebsites.net"));
+    const expectedUploadUrl = `https://${scmDomain}/api/zipdeploy/`;
+
     const service = createService();
     await service.uploadFunctions(app);
+
+    expect((FunctionAppService.prototype as any).sendFile).toBeCalledWith({
+      method: "POST",
+      uri: expectedUploadUrl,
+      json: true,
+      headers: {
+        Authorization: `Bearer ${variables["azureCredentials"].tokenCache._entries[0].accessToken}`,
+        Accept: "*/*",
+        ContentType: "application/octet-stream",
+      }
+    }, slsService["artifact"])
+  });
+
+  it("uploads functions with custom SCM domain (aka App service environments)", async () => {
+    const customApp = {
+      ...MockFactory.createTestSite("CustomAppWithinASE"),
+      enabledHostNames: [
+        "myapi.customase.p.azurewebsites.net",
+        "myapi.scm.customase.p.azurewebsites.net"
+      ],
+    }
+
+    const expectedUploadUrl = `https://${customApp.enabledHostNames[1]}/api/zipdeploy/`;
+
+    const service = createService();
+    await service.uploadFunctions(customApp);
+
     expect((FunctionAppService.prototype as any).sendFile).toBeCalledWith({
       method: "POST",
-      uri: uploadUrl,
+      uri: expectedUploadUrl,
       json: true,
       headers: {
         Authorization: `Bearer ${variables["azureCredentials"].tokenCache._entries[0].accessToken}`,
diff --git a/src/test/mockFactory.ts b/src/test/mockFactory.ts
index d6fa7a8..7962cd7 100644
--- a/src/test/mockFactory.ts
+++ b/src/test/mockFactory.ts
@@ -1,20 +1,14 @@
-import { ApiContract, ApiManagementServiceResource } from "@azure/arm-apimanagement/esm/models";
-import { Site, FunctionEnvelope } from "@azure/arm-appservice/esm/models";
+import { DeploymentsListByResourceGroupResponse } from "@azure/arm-resources/esm/models";
 import { HttpHeaders, HttpOperationResponse, HttpResponse, WebResource } from "@azure/ms-rest-js";
-import { AuthResponse, LinkedSubscription, TokenCredentialsBase } from "@azure/ms-rest-nodeauth";
-import { TokenClientCredentials, TokenResponse } from "@azure/ms-rest-nodeauth/dist/lib/credentials/tokenClientCredentials";
-import { AxiosRequestConfig, AxiosResponse } from "axios";
+import { LinkedSubscription, TokenCredentialsBase } from "@azure/ms-rest-nodeauth";
+import { TokenResponse } from "@azure/ms-rest-nodeauth/dist/lib/credentials/tokenClientCredentials";
+import { ServiceListContainersSegmentResponse } from "@azure/storage-blob/typings/lib/generated/lib/models";
+import { AxiosResponse } from "axios";
 import yaml from "js-yaml";
 import Serverless from "serverless";
 import Service from "serverless/classes/Service";
 import Utils from "serverless/classes/Utils";
 import PluginManager from "serverless/lib/classes/PluginManager";
-import { ServerlessAzureConfig } from "../models/serverless";
-import { AzureServiceProvider, ServicePrincipalEnvVariables } from "../models/azureProvider"
-import { Logger } from "../models/generic";
-import { ApiCorsPolicy, ApiManagementConfig } from "../models/apiManagement";
-import { DeploymentsListByResourceGroupResponse } from "@azure/arm-resources/esm/models";
-import { ArmResourceTemplate } from "../models/armTemplates";
 
 function getAttribute(object: any, prop: string, defaultValue: any): any {
   if (object && object[prop]) {
@@ -183,6 +177,39 @@ export class MockFactory {
     return Promise.resolve(response);
   }
 
+  public static createTestAzureContainers(count: number = 5): ServiceListContainersSegmentResponse {
+    const result = [];
+    for (let i = 0; i < count; i++) {
+      result.push({
+        name: `container${i}`,
+        blobs: MockFactory.createTestAzureBlobItems(i),
+      });
+    }
+    return { containerItems: result } as ServiceListContainersSegmentResponse;
+  }
+
+  public static createTestBlockBlobUrl(containerName: string, blobName: string) {
+    return {
+      containerName,
+      blobName,
+      delete: jest.fn(),
+    }
+  }
+
+  public static createTestAzureBlobItems(id: number = 1, count: number = 5) {
+    const result = [];
+    for (let i = 0; i < count; i++) {
+      result.push(MockFactory.createTestAzureBlobItem(id, i));
+    }
+    return { segment: { blobItems: result } };
+  }
+
+  public static createTestAzureBlobItem(id: number = 1, index: number = 1, ext: string = ".zip") {
+    return  {
+      name: `blob-${id}-${index}${ext}`
+    }
+  }
+
   public static createTestAzureClientResponse<T>(responseJson: T, statusCode: number = 200): Promise<HttpOperationResponse> {
     const response: HttpOperationResponse = {
       request: new WebResource(),