From b90bf5d605007b521f8666bed22415b49a923a5b Mon Sep 17 00:00:00 2001 From: Tanner Barlow Date: Fri, 21 Jun 2019 16:36:37 -0700 Subject: [PATCH 1/2] feat: Azure Blob Storage service --- package-lock.json | 83 +++++------ package.json | 1 + src/plugins/deploy/azureDeployPlugin.test.ts | 34 +++++ src/plugins/deploy/azureDeployPlugin.ts | 37 ++++- src/plugins/login/loginPlugin.ts | 3 +- src/services/azureBlobStorageService.test.ts | 92 ++++++++++++ src/services/azureBlobStorageService.ts | 149 +++++++++++++++++++ src/services/baseService.ts | 6 + src/services/resourceService.test.ts | 15 +- src/services/resourceService.ts | 5 + src/test/mockFactory.ts | 48 ++++++ 11 files changed, 424 insertions(+), 49 deletions(-) create mode 100644 src/services/azureBlobStorageService.test.ts create mode 100644 src/services/azureBlobStorageService.ts diff --git a/package-lock.json b/package-lock.json index 98cfecab..4a99e0c4 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", @@ -1753,7 +1778,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", @@ -1926,7 +1950,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", @@ -2396,8 +2419,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", @@ -2502,8 +2524,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", @@ -2532,7 +2553,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", @@ -2569,7 +2589,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" @@ -2666,8 +2685,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", @@ -2849,7 +2867,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" @@ -3227,7 +3244,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", @@ -3240,7 +3256,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", @@ -3699,7 +3714,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", @@ -3761,7 +3775,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" } @@ -4141,7 +4154,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" @@ -4150,14 +4162,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" @@ -5455,7 +5465,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" @@ -5465,7 +5474,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" @@ -5475,7 +5483,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", @@ -5794,8 +5801,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", @@ -7435,8 +7441,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", @@ -7527,7 +7532,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", @@ -7553,7 +7557,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" @@ -7644,14 +7647,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", @@ -8249,7 +8250,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", @@ -8358,7 +8358,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", @@ -8561,8 +8560,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", @@ -8629,7 +8627,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" } @@ -9042,7 +9039,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" @@ -9432,7 +9428,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" @@ -9628,8 +9623,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", @@ -10186,8 +10180,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", @@ -11037,7 +11030,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" @@ -11046,8 +11038,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 744b556f..b1020fba 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/plugins/deploy/azureDeployPlugin.test.ts b/src/plugins/deploy/azureDeployPlugin.test.ts index 2818890a..3c3db2f2 100644 --- a/src/plugins/deploy/azureDeployPlugin.test.ts +++ b/src/plugins/deploy/azureDeployPlugin.test.ts @@ -10,6 +10,11 @@ import { ResourceService } from "../../services/resourceService"; import { Site } from "@azure/arm-appservice/esm/models"; describe("Deploy plugin", () => { + + afterEach(() => { + jest.resetAllMocks(); + }) + it("calls deploy hook", async () => { const deployResourceGroup = jest.fn(); const functionAppStub: Site = MockFactory.createTestSite(); @@ -30,4 +35,33 @@ describe("Deploy plugin", () => { expect(deploy).toBeCalled(); expect(uploadFunctions).toBeCalledWith(functionAppStub); }); + + it("lists deployments", async () => { + const deployments = MockFactory.createTestDeployments(); + ResourceService.prototype.getDeployments = jest.fn(() => Promise.resolve(deployments)); + const sls = MockFactory.createTestServerless(); + const options = MockFactory.createTestServerlessOptions(); + const plugin = new AzureDeployPlugin(sls, options); + await invokeHook(plugin, "deploy:list:list"); + let expectedLogStatement = "\n\nDeployments"; + for (const dep of deployments) { + expectedLogStatement += "\n-----------\n" + expectedLogStatement += `Name: ${dep.name}\n` + expectedLogStatement += `Timestamp: ${dep.properties.timestamp.getTime()}\n`; + expectedLogStatement += `Datetime: ${dep.properties.timestamp.toISOString()}\n` + } + expectedLogStatement += "-----------\n" + expect(sls.cli.log).lastCalledWith(expectedLogStatement); + }); + + it("logs empty deployment list", async () => { + const sls = MockFactory.createTestServerless(); + const resourceGroup = "rg1"; + ResourceService.prototype.getDeployments = jest.fn(() => Promise.resolve([])) as any; + ResourceService.prototype.getResourceGroup = jest.fn(() => resourceGroup); + const options = MockFactory.createTestServerlessOptions(); + const plugin = new AzureDeployPlugin(sls, options); + await invokeHook(plugin, "deploy:list:list"); + expect(sls.cli.log).lastCalledWith(`No deployments found for resource group '${resourceGroup}'`); + }); }); diff --git a/src/plugins/deploy/azureDeployPlugin.ts b/src/plugins/deploy/azureDeployPlugin.ts index 48f09674..76c88ebc 100644 --- a/src/plugins/deploy/azureDeployPlugin.ts +++ b/src/plugins/deploy/azureDeployPlugin.ts @@ -4,11 +4,46 @@ import { FunctionAppService } from "../../services/functionAppService"; export class AzureDeployPlugin { public hooks: { [eventName: string]: Promise }; + public commands: any; public constructor(private serverless: Serverless, private options: Serverless.Options) { this.hooks = { - "deploy:deploy": this.deploy.bind(this) + "deploy:deploy": this.deploy.bind(this), + "deploy:list:list": this.list.bind(this), }; + + this.commands = { + deploy: { + commands: { + list: { + usage: "List deployments", + lifecycleEvents: [ + "list" + ] + } + } + } + } + } + + private async list() { + this.serverless.cli.log("Listing deployments"); + const resourceService = new ResourceService(this.serverless, this.options); + const deployments = await resourceService.getDeployments(); + if (!deployments || deployments.length === 0) { + this.serverless.cli.log(`No deployments found for resource group '${resourceService.getResourceGroup()}'`); + return; + } + let stringDeployments = "\n\nDeployments"; + + for (const dep of deployments) { + stringDeployments += "\n-----------\n" + stringDeployments += `Name: ${dep.name}\n` + stringDeployments += `Timestamp: ${dep.properties.timestamp.getTime()}\n`; + stringDeployments += `Datetime: ${dep.properties.timestamp.toISOString()}\n` + } + stringDeployments += "-----------\n" + this.serverless.cli.log(stringDeployments); } private async deploy() { diff --git a/src/plugins/login/loginPlugin.ts b/src/plugins/login/loginPlugin.ts index d64635e6..083831f6 100644 --- a/src/plugins/login/loginPlugin.ts +++ b/src/plugins/login/loginPlugin.ts @@ -10,7 +10,8 @@ export class AzureLoginPlugin { this.provider = (this.serverless.getProvider("azure") as any) as AzureProvider; this.hooks = { - "before:package:initialize": this.login.bind(this) + "before:package:initialize": this.login.bind(this), + "before:deploy:list:list": this.login.bind(this), }; } diff --git a/src/services/azureBlobStorageService.test.ts b/src/services/azureBlobStorageService.test.ts new file mode 100644 index 00000000..3a23944f --- /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("uploads 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("deletes a file", async () => { + ContainerURL.fromServiceURL = jest.fn((serviceUrl, containerName) => (containerName as any)); + await service.deleteFile(containerName, fileName); + expect(blockBlobUrl.delete).toBeCalledWith(Aborter.none) + }); + + it("lists 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("lists 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("creates 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("deletes 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/azureBlobStorageService.ts b/src/services/azureBlobStorageService.ts new file mode 100644 index 00000000..29c82938 --- /dev/null +++ b/src/services/azureBlobStorageService.ts @@ -0,0 +1,149 @@ +import { Aborter, BlockBlobURL, ContainerURL, ServiceURL, StorageURL, uploadFileToBlockBlob } from "@azure/storage-blob"; +import Serverless from "serverless"; +import { BaseService } from "./baseService"; + +/** + * 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) { + 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 { + 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 { + 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 { + const containerURL = this.getContainerURL(containerName); + try { + await containerURL.create(Aborter.none); + } catch (e) { + if (e.statusCode === 409) { + return; + } + + throw e; + } + } + + /** + * Delete a container from Azure Blob Storage Account + * @param containerName Name of container to delete + */ + public async deleteContainer(containerName: string): Promise { + await this.getContainerURL(containerName).delete(Aborter.none); + } + + /** + * Get ServiceURL object for Azure Blob Storage Account + */ + private getServiceURL(): ServiceURL { + const credential = this.credentials + const pipeline = StorageURL.newPipeline(credential); + 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, serviceURL?: ServiceURL): ContainerURL { + return ContainerURL.fromServiceURL( + (serviceURL) ? serviceURL : 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 { + const containerURL = this.getContainerURL(containerName); + return BlockBlobURL.fromContainerURL( + containerURL, + blobName, + ); + } +} diff --git a/src/services/baseService.ts b/src/services/baseService.ts index 3f3c73a4..fc74bc90 100644 --- a/src/services/baseService.ts +++ b/src/services/baseService.ts @@ -11,6 +11,8 @@ export abstract class BaseService { protected subscriptionId: string; protected resourceGroup: string; protected deploymentName: string; + protected deploymentContainerName: string; + protected storageAccountName: string; protected constructor( protected serverless: Serverless, @@ -33,6 +35,10 @@ export abstract class BaseService { } } + public getResourceGroup(): string { + return this.resourceGroup; + } + /** * Sends an API request using axios HTTP library * @param method The HTTP method diff --git a/src/services/resourceService.test.ts b/src/services/resourceService.test.ts index f664b32b..22ac5ad1 100644 --- a/src/services/resourceService.test.ts +++ b/src/services/resourceService.test.ts @@ -6,6 +6,7 @@ jest.mock("@azure/arm-resources") import { ResourceManagementClient } from "@azure/arm-resources"; describe("Resource Service", () => { + const deployments = MockFactory.createTestDeployments(); beforeAll(() => { ResourceManagementClient.prototype.resourceGroups = { @@ -14,7 +15,8 @@ describe("Resource Service", () => { } as any; ResourceManagementClient.prototype.deployments = { - deleteMethod: jest.fn() + deleteMethod: jest.fn(), + listByResourceGroup: jest.fn(() => Promise.resolve(deployments)), } as any; }); @@ -70,4 +72,15 @@ describe("Resource Service", () => { expect(ResourceManagementClient.prototype.resourceGroups.deleteMethod) .toBeCalledWith(resourceGroup); }); + + it("lists deployments", async () => { + const sls = MockFactory.createTestServerless(); + const resourceGroup = "myResourceGroup"; + sls.service.provider["resourceGroup"] = resourceGroup + sls.variables["azureCredentials"] = "fake credentials" + const options = MockFactory.createTestServerlessOptions(); + const service = new ResourceService(sls, options); + const deps = await service.getDeployments(); + expect(deps).toEqual(deployments); + }); }); \ No newline at end of file diff --git a/src/services/resourceService.ts b/src/services/resourceService.ts index 8d47c01c..e36dd909 100644 --- a/src/services/resourceService.ts +++ b/src/services/resourceService.ts @@ -11,6 +11,11 @@ export class ResourceService extends BaseService { this.resourceClient = new ResourceManagementClient(this.credentials, this.subscriptionId); } + public async getDeployments() { + this.log(`Listing deployments for resource group '${this.resourceGroup}':`); + return await this.resourceClient.deployments.listByResourceGroup(this.resourceGroup); + } + public async deployResourceGroup() { this.log(`Creating resource group: ${this.resourceGroup}`); diff --git a/src/test/mockFactory.ts b/src/test/mockFactory.ts index ac72bc39..f527ee72 100644 --- a/src/test/mockFactory.ts +++ b/src/test/mockFactory.ts @@ -13,6 +13,8 @@ import { ServerlessAzureConfig } from "../models/serverless"; import { AzureServiceProvider, ServicePrincipalEnvVariables } from "../models/azureProvider" import { Logger } from "../models/generic"; import { ApiCorsPolicy } from "../models/apiManagement"; +import { ServiceListContainersSegmentResponse } from "@azure/storage-blob/typings/lib/generated/lib/models"; +import { DeploymentsListByResourceGroupResponse } from "@azure/arm-resources/esm/models"; function getAttribute(object: any, prop: string, defaultValue: any): any { if (object && object[prop]) { @@ -140,6 +142,19 @@ export class MockFactory { return credentials; } + public static createTestDeployments(count: number = 5): DeploymentsListByResourceGroupResponse { + const result = []; + for (let i = 0; i < count; i++) { + result.push({ + name: `deployment${i+1}`, + properties: { + timestamp: new Date(), + } + }) + } + return result as DeploymentsListByResourceGroupResponse + } + public static createTestAxiosResponse( config: AxiosRequestConfig, responseJson: T, @@ -168,6 +183,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(responseJson: T, statusCode: number = 200): Promise { const response: HttpOperationResponse = { request: new WebResource(), From 7552363614d17c2ecb5fbf2b8ea9415656f6d7f2 Mon Sep 17 00:00:00 2001 From: Tanner Barlow Date: Mon, 24 Jun 2019 07:17:17 -0700 Subject: [PATCH 2/2] Remove unused code --- src/services/azureBlobStorageService.test.ts | 2 +- src/services/azureBlobStorageService.ts | 20 ++++++-------------- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/src/services/azureBlobStorageService.test.ts b/src/services/azureBlobStorageService.test.ts index 3a23944f..d9987deb 100644 --- a/src/services/azureBlobStorageService.test.ts +++ b/src/services/azureBlobStorageService.test.ts @@ -80,7 +80,7 @@ describe("Azure Blob Storage Service", () => { expect(ContainerURL.fromServiceURL).toBeCalledWith(expect.anything(), newContainerName); expect(ContainerURL.prototype.create).toBeCalledWith(Aborter.none); }); - + it("deletes a container", async () => { const containerToDelete = "delete container"; ContainerURL.fromServiceURL = jest.fn(() => new ContainerURL(null, null)); diff --git a/src/services/azureBlobStorageService.ts b/src/services/azureBlobStorageService.ts index 29c82938..67e6aa2a 100644 --- a/src/services/azureBlobStorageService.ts +++ b/src/services/azureBlobStorageService.ts @@ -89,15 +89,7 @@ export class AzureBlobStorageService extends BaseService { */ public async createContainer(containerName: string): Promise { const containerURL = this.getContainerURL(containerName); - try { - await containerURL.create(Aborter.none); - } catch (e) { - if (e.statusCode === 409) { - return; - } - - throw e; - } + await containerURL.create(Aborter.none); } /** @@ -105,7 +97,8 @@ export class AzureBlobStorageService extends BaseService { * @param containerName Name of container to delete */ public async deleteContainer(containerName: string): Promise { - await this.getContainerURL(containerName).delete(Aborter.none); + const containerUrl = await this.getContainerURL(containerName) + await containerUrl.delete(Aborter.none); } /** @@ -127,9 +120,9 @@ export class AzureBlobStorageService extends BaseService { * @param containerName Name of container * @param serviceURL Previously created ServiceURL object (will create if undefined) */ - private getContainerURL(containerName: string, serviceURL?: ServiceURL): ContainerURL { + private getContainerURL(containerName: string): ContainerURL { return ContainerURL.fromServiceURL( - (serviceURL) ? serviceURL : this.getServiceURL(), + this.getServiceURL(), containerName ); } @@ -140,9 +133,8 @@ export class AzureBlobStorageService extends BaseService { * @param blobName Name of blob */ private getBlockBlobURL(containerName: string, blobName: string): BlockBlobURL { - const containerURL = this.getContainerURL(containerName); return BlockBlobURL.fromContainerURL( - containerURL, + this.getContainerURL(containerName), blobName, ); }