From cb1f29548b0f2922a90fcb64928912d86f5dbc46 Mon Sep 17 00:00:00 2001 From: Tanner Barlow Date: Fri, 5 Jul 2019 19:55:32 -0700 Subject: [PATCH] feat: Rollback Plugin (#191) ## Updates - Adds new `rollback` plugin - Fixes `deploy list` to show actual timestamp from name, which is used for rollback - Adds required functionality to `AzureBlobStorageService` and `ArmService` - Adds previously missing tests of required functionality ## How to Use 1. Get a timestamp. Use ``` sls deploy list ``` to retrieve one. 2. Roll back function app to timestamp ``` sls rollback -t ``` ## Options In `deploy` section of `serverless.yml`, you can specify whether to run from a blob URL or deploy the package directly to the function app by setting: ```yaml deploy: # Default is false runFromBlobUrl: true|false ``` Resolves [AB#317] --- package-lock.json | 96 +++-------- src/armTemplates/resources/functionApp.ts | 6 +- src/config.ts | 7 +- src/index.ts | 2 + src/models/serverless.ts | 1 + src/plugins/deploy/azureDeployPlugin.test.ts | 28 +++- src/plugins/deploy/azureDeployPlugin.ts | 11 +- src/plugins/login/loginPlugin.ts | 1 + .../rollback/azureRollbackPlugin.test.ts | 17 ++ src/plugins/rollback/azureRollbackPlugin.ts | 20 +++ src/services/armService.ts | 16 +- src/services/azureBlobStorageService.test.ts | 57 ++++++- src/services/azureBlobStorageService.ts | 39 ++++- src/services/baseService.ts | 45 +++++- src/services/functionAppService.test.ts | 2 +- src/services/functionAppService.ts | 20 +-- src/services/resourceService.test.ts | 19 +++ src/services/resourceService.ts | 11 ++ src/services/rollbackService.test.ts | 130 +++++++++++++++ src/services/rollbackService.ts | 150 ++++++++++++++++++ src/shared/utils.test.ts | 8 + src/shared/utils.ts | 16 ++ src/test/mockFactory.ts | 50 ++++-- 23 files changed, 632 insertions(+), 120 deletions(-) create mode 100644 src/plugins/rollback/azureRollbackPlugin.test.ts create mode 100644 src/plugins/rollback/azureRollbackPlugin.ts create mode 100644 src/services/rollbackService.test.ts create mode 100644 src/services/rollbackService.ts diff --git a/package-lock.json b/package-lock.json index b52ea548..f756d844 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1794,7 +1794,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", @@ -1967,7 +1966,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", @@ -2437,8 +2435,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", @@ -2543,8 +2540,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", @@ -2573,7 +2569,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", @@ -2610,7 +2605,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" @@ -2707,8 +2701,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", @@ -2895,7 +2888,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" @@ -3273,7 +3265,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", @@ -3286,7 +3277,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", @@ -3750,7 +3740,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", @@ -3812,7 +3801,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" } @@ -4192,7 +4180,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" @@ -4207,7 +4194,6 @@ "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" @@ -4737,8 +4723,7 @@ }, "ansi-regex": { "version": "2.1.1", - "bundled": true, - "optional": true + "bundled": true }, "aproba": { "version": "1.2.0", @@ -4756,13 +4741,11 @@ }, "balanced-match": { "version": "1.0.0", - "bundled": true, - "optional": true + "bundled": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, - "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -4775,18 +4758,15 @@ }, "code-point-at": { "version": "1.1.0", - "bundled": true, - "optional": true + "bundled": true }, "concat-map": { "version": "0.0.1", - "bundled": true, - "optional": true + "bundled": true }, "console-control-strings": { "version": "1.1.0", - "bundled": true, - "optional": true + "bundled": true }, "core-util-is": { "version": "1.0.2", @@ -4889,8 +4869,7 @@ }, "inherits": { "version": "2.0.3", - "bundled": true, - "optional": true + "bundled": true }, "ini": { "version": "1.3.5", @@ -4900,7 +4879,6 @@ "is-fullwidth-code-point": { "version": "1.0.0", "bundled": true, - "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -4913,20 +4891,17 @@ "minimatch": { "version": "3.0.4", "bundled": true, - "optional": true, "requires": { "brace-expansion": "^1.1.7" } }, "minimist": { "version": "0.0.8", - "bundled": true, - "optional": true + "bundled": true }, "minipass": { "version": "2.3.5", "bundled": true, - "optional": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -4943,7 +4918,6 @@ "mkdirp": { "version": "0.5.1", "bundled": true, - "optional": true, "requires": { "minimist": "0.0.8" } @@ -5016,8 +4990,7 @@ }, "number-is-nan": { "version": "1.0.1", - "bundled": true, - "optional": true + "bundled": true }, "object-assign": { "version": "4.1.1", @@ -5027,7 +5000,6 @@ "once": { "version": "1.4.0", "bundled": true, - "optional": true, "requires": { "wrappy": "1" } @@ -5103,8 +5075,7 @@ }, "safe-buffer": { "version": "5.1.2", - "bundled": true, - "optional": true + "bundled": true }, "safer-buffer": { "version": "2.1.2", @@ -5134,7 +5105,6 @@ "string-width": { "version": "1.0.2", "bundled": true, - "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -5152,7 +5122,6 @@ "strip-ansi": { "version": "3.0.1", "bundled": true, - "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -5191,13 +5160,11 @@ }, "wrappy": { "version": "1.0.2", - "bundled": true, - "optional": true + "bundled": true }, "yallist": { "version": "3.0.3", - "bundled": true, - "optional": true + "bundled": true } } }, @@ -5505,7 +5472,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" @@ -5515,7 +5481,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" @@ -5525,7 +5490,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", @@ -5844,8 +5808,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", @@ -7485,8 +7448,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", @@ -7587,7 +7549,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", @@ -7613,7 +7574,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" @@ -7704,14 +7664,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", @@ -8309,7 +8267,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", @@ -8418,7 +8375,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", @@ -8621,8 +8577,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", @@ -8689,7 +8644,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" } @@ -9102,7 +9056,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" @@ -9492,7 +9445,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" @@ -9688,8 +9640,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", @@ -10246,8 +10197,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", @@ -11097,7 +11047,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" @@ -11106,8 +11055,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/src/armTemplates/resources/functionApp.ts b/src/armTemplates/resources/functionApp.ts index d2058a38..48c7f5cb 100644 --- a/src/armTemplates/resources/functionApp.ts +++ b/src/armTemplates/resources/functionApp.ts @@ -16,6 +16,10 @@ export class FunctionAppResource implements ArmResourceTemplateGenerator { "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", "contentVersion": "1.0.0.0", "parameters": { + "functionAppRunFromPackage": { + "defaultValue": "1", + "type": "String" + }, "functionAppName": { "defaultValue": "", "type": "String" @@ -86,7 +90,7 @@ export class FunctionAppResource implements ArmResourceTemplateGenerator { }, { "name": "WEBSITE_RUN_FROM_PACKAGE", - "value": "1" + "value": "[parameters('functionAppRunFromPackage')]" }, { "name": "APPINSIGHTS_INSTRUMENTATIONKEY", diff --git a/src/config.ts b/src/config.ts index f24db2d1..9cf10474 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,10 @@ export const configConstants = { bearer: "Bearer ", - deploymentArtifactContainer: "deployment-artifacts", + deploymentConfig: { + container: "deployment-artifacts", + rollback: true, + runFromBlobUrl: false, + }, functionAppApiPath: "/api/", functionAppDomain: ".azurewebsites.net", functionsAdminApiPath: "/admin/functions/", @@ -11,7 +15,6 @@ export const configConstants = { logStreamApiPath: "/api/logstream/application/functions/function/", masterKeyApiPath: "/api/functions/admin/masterkey", providerName: "azure", - rollbackEnabled: true, scmCommandApiPath: "/api/command", scmDomain: ".scm.azurewebsites.net", scmVfsPath: "/api/vfs/site/wwwroot/", diff --git a/src/index.ts b/src/index.ts index 42c35e96..4ad846ab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ import { AzureApimServicePlugin } from "./plugins/apim/apimServicePlugin"; import { AzureApimFunctionPlugin } from "./plugins/apim/apimFunctionPlugin"; import { AzureFuncPlugin } from "./plugins/func/azureFunc"; import { AzureOfflinePlugin } from "./plugins/offline/azureOfflinePlugin" +import { AzureRollbackPlugin } from "./plugins/rollback/azureRollbackPlugin" export default class AzureIndex { @@ -34,6 +35,7 @@ export default class AzureIndex { this.serverless.pluginManager.addPlugin(AzureApimFunctionPlugin); this.serverless.pluginManager.addPlugin(AzureFuncPlugin); this.serverless.pluginManager.addPlugin(AzureOfflinePlugin); + this.serverless.pluginManager.addPlugin(AzureRollbackPlugin); } } diff --git a/src/models/serverless.ts b/src/models/serverless.ts index dc7f0b91..2f009ab8 100644 --- a/src/models/serverless.ts +++ b/src/models/serverless.ts @@ -27,6 +27,7 @@ export interface FunctionAppConfig extends ResourceConfig { export interface DeploymentConfig { rollback?: boolean; container?: string; + runFromBlobUrl?: boolean; } export interface ServerlessAzureProvider { diff --git a/src/plugins/deploy/azureDeployPlugin.test.ts b/src/plugins/deploy/azureDeployPlugin.test.ts index f4ae441b..38136753 100644 --- a/src/plugins/deploy/azureDeployPlugin.test.ts +++ b/src/plugins/deploy/azureDeployPlugin.test.ts @@ -36,7 +36,29 @@ describe("Deploy plugin", () => { expect(uploadFunctions).toBeCalledWith(functionAppStub); }); - it("lists deployments", async () => { + it("lists deployments with timestamps", async () => { + const deployments = MockFactory.createTestDeployments(5, true); + 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"; + const originalTimestamp = +MockFactory.createTestTimestamp(); + let i = 0 + for (const dep of deployments) { + const timestamp = originalTimestamp + i + expectedLogStatement += "\n-----------\n" + expectedLogStatement += `Name: ${dep.name}\n` + expectedLogStatement += `Timestamp: ${timestamp}\n`; + expectedLogStatement += `Datetime: ${new Date(timestamp).toISOString()}\n` + i++ + } + expectedLogStatement += "-----------\n" + expect(sls.cli.log).lastCalledWith(expectedLogStatement); + }); + + it("lists deployments without timestamps", async () => { const deployments = MockFactory.createTestDeployments(); ResourceService.prototype.getDeployments = jest.fn(() => Promise.resolve(deployments)); const sls = MockFactory.createTestServerless(); @@ -47,8 +69,8 @@ describe("Deploy plugin", () => { 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 += "Timestamp: None\n"; + expectedLogStatement += "Datetime: None\n" } expectedLogStatement += "-----------\n" expect(sls.cli.log).lastCalledWith(expectedLogStatement); diff --git a/src/plugins/deploy/azureDeployPlugin.ts b/src/plugins/deploy/azureDeployPlugin.ts index 88e253cd..a17f23c3 100644 --- a/src/plugins/deploy/azureDeployPlugin.ts +++ b/src/plugins/deploy/azureDeployPlugin.ts @@ -1,6 +1,7 @@ import Serverless from "serverless"; import { FunctionAppService } from "../../services/functionAppService"; import { ResourceService } from "../../services/resourceService"; +import { Utils } from "../../shared/utils"; export class AzureDeployPlugin { public hooks: { [eventName: string]: Promise }; @@ -45,8 +46,14 @@ export class AzureDeployPlugin { 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` + const timestampFromName = Utils.getTimestampFromName(dep.name); + stringDeployments += `Timestamp: ${(timestampFromName) ? timestampFromName : "None"}\n`; + stringDeployments += `Datetime: ${(timestampFromName) + ? + new Date(+timestampFromName).toISOString() + : + "None" + }\n` } stringDeployments += "-----------\n" this.serverless.cli.log(stringDeployments); diff --git a/src/plugins/login/loginPlugin.ts b/src/plugins/login/loginPlugin.ts index 083831f6..1b36eb68 100644 --- a/src/plugins/login/loginPlugin.ts +++ b/src/plugins/login/loginPlugin.ts @@ -12,6 +12,7 @@ export class AzureLoginPlugin { this.hooks = { "before:package:initialize": this.login.bind(this), "before:deploy:list:list": this.login.bind(this), + "before:rollback:rollback": this.login.bind(this), }; } diff --git a/src/plugins/rollback/azureRollbackPlugin.test.ts b/src/plugins/rollback/azureRollbackPlugin.test.ts new file mode 100644 index 00000000..ac636648 --- /dev/null +++ b/src/plugins/rollback/azureRollbackPlugin.test.ts @@ -0,0 +1,17 @@ +import { MockFactory } from "../../test/mockFactory"; +import { invokeHook } from "../../test/utils"; +import { AzureRollbackPlugin } from "./azureRollbackPlugin"; + +jest.mock("../../services/rollbackService"); +import { RollbackService } from "../../services/rollbackService"; + +describe("Rollback Plugin", () => { + it("should call rollback service", async () => { + const plugin = new AzureRollbackPlugin( + MockFactory.createTestServerless(), + MockFactory.createTestServerlessOptions(), + ); + await invokeHook(plugin, "rollback:rollback"); + expect(RollbackService.prototype.rollback).toBeCalled(); + }) +}); diff --git a/src/plugins/rollback/azureRollbackPlugin.ts b/src/plugins/rollback/azureRollbackPlugin.ts new file mode 100644 index 00000000..a3c24e30 --- /dev/null +++ b/src/plugins/rollback/azureRollbackPlugin.ts @@ -0,0 +1,20 @@ +import Serverless from "serverless"; +import { RollbackService } from "../../services/rollbackService"; + +/** + * Plugin for rolling back Function App Service to previous deployment + */ +export class AzureRollbackPlugin { + public hooks: { [eventName: string]: Promise }; + + public constructor(private serverless: Serverless, private options: Serverless.Options) { + this.hooks = { + "rollback:rollback": this.rollback.bind(this) + }; + } + + private async rollback() { + const service = new RollbackService(this.serverless, this.options); + await service.rollback(); + } +} diff --git a/src/services/armService.ts b/src/services/armService.ts index df1afe8f..d021b360 100644 --- a/src/services/armService.ts +++ b/src/services/armService.ts @@ -1,13 +1,13 @@ -import Serverless from "serverless"; -import { Deployment, DeploymentExtended } from "@azure/arm-resources/esm/models"; -import { BaseService } from "./baseService"; import { ResourceManagementClient } from "@azure/arm-resources"; -import { Guard } from "../shared/guard"; -import { ServerlessAzureConfig, ArmTemplateConfig, ServerlessAzureOptions } from "../models/serverless"; -import { ArmDeployment, ArmResourceTemplateGenerator, ArmTemplateType } from "../models/armTemplates"; +import { Deployment, DeploymentExtended } from "@azure/arm-resources/esm/models"; import fs from "fs"; -import path from "path"; import jsonpath from "jsonpath"; +import path from "path"; +import Serverless from "serverless"; +import { ArmDeployment, ArmResourceTemplateGenerator, ArmTemplateType } from "../models/armTemplates"; +import { ArmTemplateConfig, ServerlessAzureConfig, ServerlessAzureOptions } from "../models/serverless"; +import { Guard } from "../shared/guard"; +import { BaseService } from "./baseService"; export class ArmService extends BaseService { private resourceClient: ResourceManagementClient; @@ -97,7 +97,7 @@ export class ArmService extends BaseService { Object.keys(deployment.parameters).forEach((key) => { const parameterValue = deployment.parameters[key]; if (parameterValue) { - deploymentParameters[key] = { value: deployment.parameters[key] }; + deploymentParameters[key] = { value: parameterValue } } }); diff --git a/src/services/azureBlobStorageService.test.ts b/src/services/azureBlobStorageService.test.ts index 7d37cb77..6eb6e62e 100644 --- a/src/services/azureBlobStorageService.test.ts +++ b/src/services/azureBlobStorageService.test.ts @@ -4,7 +4,7 @@ import { AzureBlobStorageService, AzureStorageAuthType } from "./azureBlobStorag jest.mock("@azure/storage-blob"); jest.genMockFromModule("@azure/storage-blob") -import { Aborter, BlockBlobURL, ContainerURL, ServiceURL, uploadFileToBlockBlob, TokenCredential, SharedKeyCredential } from "@azure/storage-blob"; +import { Aborter, BlockBlobURL, ContainerURL, ServiceURL, uploadFileToBlockBlob, TokenCredential, SharedKeyCredential, downloadBlobToBuffer, generateBlobSASQueryParameters, BlobSASPermissions } from "@azure/storage-blob"; jest.mock("@azure/arm-storage") jest.genMockFromModule("@azure/arm-storage"); @@ -14,6 +14,9 @@ jest.mock("./loginService"); import { AzureLoginService } from "./loginService" import { StorageAccountResource } from "../armTemplates/resources/storageAccount"; +jest.mock("fs") +import fs from "fs"; + describe("Azure Blob Storage Service", () => { const filePath = "deployments/deployment.zip"; const fileName = "deployment.zip"; @@ -24,7 +27,8 @@ describe("Azure Blob Storage Service", () => { const sls = MockFactory.createTestServerless(); const accountName = StorageAccountResource.getResourceName(sls.service as any); const options = MockFactory.createTestServerlessOptions(); - const blockBlobUrl = MockFactory.createTestBlockBlobUrl(containerName, filePath); + const blobContent = "testContent"; + const blockBlobUrl = MockFactory.createTestBlockBlobUrl(containerName, filePath, blobContent); let service: AzureBlobStorageService; const token = "myToken"; @@ -130,6 +134,14 @@ describe("Azure Blob Storage Service", () => { expect(ContainerURL.fromServiceURL).toBeCalledWith(expect.anything(), newContainerName); expect(ContainerURL.prototype.create).toBeCalledWith(Aborter.none); }); + + it("should not create a container if it exists already", async () => { + ContainerURL.fromServiceURL = jest.fn(() => new ContainerURL(null, null)); + ContainerURL.prototype.create = jest.fn(() => Promise.resolve({ statusCode: 201 })) as any; + await service.createContainerIfNotExists("container1"); + expect(ContainerURL.fromServiceURL).not.toBeCalled(); + expect(ContainerURL.prototype.create).not.toBeCalled + }) it("should delete a container", async () => { const containerToDelete = "delete container"; @@ -139,4 +151,45 @@ describe("Azure Blob Storage Service", () => { expect(ContainerURL.fromServiceURL).toBeCalledWith(expect.anything(), containerToDelete); expect(ContainerURL.prototype.delete).toBeCalledWith(Aborter.none); }); + + it("should download a binary file", async () => { + const targetPath = ""; + await service.downloadBinary(containerName, fileName, targetPath); + const buffer = Buffer.alloc(blobContent.length) + expect(downloadBlobToBuffer).toBeCalledWith( + Aborter.timeout(30 * 60 * 1000), + buffer, + blockBlobUrl, + 0, + undefined, + { + blockSize: 4 * 1024 * 1024, // 4MB block size + parallelism: 20, // 20 concurrency + } + ); + expect(fs.writeFileSync).toBeCalledWith( + targetPath, + buffer, + "binary" + ) + }); + + it("should generate a SAS url", async () => { + const sasToken = "myToken" + BlobSASPermissions.parse = jest.fn(() => { + return { + toString: jest.fn() + } + }) as any; + (generateBlobSASQueryParameters as any).mockReturnValue(token); + const url = await service.generateBlobSasTokenUrl(containerName, fileName); + expect(generateBlobSASQueryParameters).toBeCalled(); + expect(url).toEqual(`${blockBlobUrl.url}?${sasToken}`) + }); + + it("should throw an error when trying to get a SAS Token with Token Auth", async () => { + const newService = new AzureBlobStorageService(sls, options, AzureStorageAuthType.Token); + await newService.initialize(); + await expect(newService.generateBlobSasTokenUrl(containerName, fileName)).rejects.not.toBeNull(); + }) }); diff --git a/src/services/azureBlobStorageService.ts b/src/services/azureBlobStorageService.ts index 6764d2ba..ba049177 100644 --- a/src/services/azureBlobStorageService.ts +++ b/src/services/azureBlobStorageService.ts @@ -1,11 +1,18 @@ import { StorageAccounts, StorageManagementClientContext } from "@azure/arm-storage"; -import { Aborter, BlobSASPermissions, BlockBlobURL, ContainerURL, generateBlobSASQueryParameters,SASProtocol, - ServiceURL, SharedKeyCredential, StorageURL, TokenCredential, uploadFileToBlockBlob } from "@azure/storage-blob"; +import { Aborter, BlobSASPermissions, BlockBlobURL, ContainerURL, + generateBlobSASQueryParameters, SASProtocol, ServiceURL, SharedKeyCredential, + StorageURL, TokenCredential, uploadFileToBlockBlob, downloadBlobToBuffer } from "@azure/storage-blob"; +import fs from "fs"; import Serverless from "serverless"; import { Guard } from "../shared/guard"; import { BaseService } from "./baseService"; import { AzureLoginService } from "./loginService"; +/** + * Type of authentication with Azure Storage + * @member SharedKey - Retrieve and use a Shared Key for Azure Blob BStorage + * @member Token - Retrieve and use an Access Token to authenticate with Azure Blob Storage + */ export enum AzureStorageAuthType { SharedKey, Token @@ -35,6 +42,9 @@ export class AzureBlobStorageService extends BaseService { * to perform any operation with the service */ public async initialize() { + if (this.storageCredential) { + return; + } this.storageCredential = (this.authType === AzureStorageAuthType.SharedKey) ? new SharedKeyCredential(this.storageAccountName, await this.getKey()) @@ -59,6 +69,31 @@ export class AzureBlobStorageService extends BaseService { await uploadFileToBlockBlob(Aborter.none, path, this.getBlockBlobURL(containerName, name)); this.log("Finished uploading blob"); }; + + /** + * Download blob to file + * https://github.com/Azure/azure-storage-js/blob/master/blob/samples/highlevel.sample.js#L82-L97 + * @param containerName Container containing blob to download + * @param blobName Blob to download + * @param targetPath Path to which blob will be downloaded + */ + public async downloadBinary(containerName: string, blobName: string, targetPath: string) { + const blockBlobUrl = this.getBlockBlobURL(containerName, blobName); + const props = await blockBlobUrl.getProperties(Aborter.none); + const buffer = Buffer.alloc(props.contentLength); + await downloadBlobToBuffer( + Aborter.timeout(30 * 60 * 1000), + buffer, + blockBlobUrl, + 0, + undefined, + { + blockSize: 4 * 1024 * 1024, // 4MB block size + parallelism: 20, // 20 concurrency + } + ); + fs.writeFileSync(targetPath, buffer, "binary"); + } /** * Delete a blob from Azure Blob Storage diff --git a/src/services/baseService.ts b/src/services/baseService.ts index 6b782d4a..aee0061a 100644 --- a/src/services/baseService.ts +++ b/src/services/baseService.ts @@ -48,10 +48,16 @@ export abstract class BaseService { } } + /** + * Name of Azure Region for deployment + */ public getRegion(): string { return this.options.region || this.config.provider.region; } + /** + * Name of current deployment stage + */ public getStage(): string { return this.options.stage || this.config.provider.stage; } @@ -60,6 +66,9 @@ export abstract class BaseService { return this.config.provider.prefix; } + /** + * Name of current resource group + */ public getResourceGroupName(): string { const regionName = Utils.createShortAzureRegionName(this.getRegion()); const stageName = Utils.createShortStageName(this.getStage()); @@ -69,24 +78,43 @@ export abstract class BaseService { || `${this.getPrefix()}-${regionName}-${stageName}-${this.serviceName}-rg`; } + /** + * Deployment config from `serverless.yml` or default. + * Defaults can be found in the `config.ts` file + */ public getDeploymentConfig(): DeploymentConfig { const providedConfig = this.serverless["deploy"] as DeploymentConfig; - const config = providedConfig || { - rollback: configConstants.rollbackEnabled, - container: configConstants.deploymentArtifactContainer - }; - return config; + return { + ...configConstants.deploymentConfig, + ...providedConfig, + } } + /** + * Name of current ARM deployment + */ public getDeploymentName(): string { const name = this.config.provider.deploymentName || `${this.resourceGroup}-deployment`; return this.rollbackConfiguredName(name); } + /** + * Name of Function App Service + */ public getServiceName(): string { return this.serverless.service["service"]; } + /** + * Get rollback-configured artifact name. Contains `-t{timestamp}` + * Takes name of deployment and replaces `rg-deployment` or `deployment` with `artifact` + */ + protected getArtifactName(deploymentName: string): string { + return `${deploymentName + .replace("rg-deployment", "artifact") + .replace("deployment", "artifact")}.zip`; + } + /** * Get the access token from credentials token cache */ @@ -142,10 +170,17 @@ export abstract class BaseService { }); } + /** + * Log message to Serverless CLI + * @param message Message to log + */ protected log(message: string) { this.serverless.cli.log(message); } + /** + * Get function objects + */ protected slsFunctions() { return this.serverless.service["functions"]; } diff --git a/src/services/functionAppService.test.ts b/src/services/functionAppService.test.ts index f923c6d2..eebc61a2 100644 --- a/src/services/functionAppService.test.ts +++ b/src/services/functionAppService.test.ts @@ -215,7 +215,7 @@ describe("Function App Service", () => { const expectedArtifactName = service.getDeploymentName().replace("rg-deployment", "artifact"); expect((AzureBlobStorageService.prototype as any).uploadFile).toBeCalledWith( slsService["artifact"], - configConstants.deploymentArtifactContainer, + configConstants.deploymentConfig.container, `${expectedArtifactName}.zip`, ) const uploadCall = ((AzureBlobStorageService.prototype as any).uploadFile).mock.calls[0]; diff --git a/src/services/functionAppService.ts b/src/services/functionAppService.ts index c29b8da3..0072afe4 100644 --- a/src/services/functionAppService.ts +++ b/src/services/functionAppService.ts @@ -14,14 +14,12 @@ import { BaseService } from "./baseService"; export class FunctionAppService extends BaseService { private webClient: WebSiteManagementClient; private blobService: AzureBlobStorageService; - private functionZipFile: string; public constructor(serverless: Serverless, options: Serverless.Options) { super(serverless, options); this.webClient = new WebSiteManagementClient(this.credentials, this.subscriptionId); this.blobService = new AzureBlobStorageService(serverless, options); - this.functionZipFile = this.getFunctionZipFile(); } public async get(): Promise { @@ -117,8 +115,10 @@ export class FunctionAppService extends BaseService { this.log("Deploying serverless functions..."); - await this.uploadZippedArfifactToFunctionApp(functionApp); - await this.uploadZippedArtifactToBlobStorage(); + const functionZipFile = this.getFunctionZipFile(); + const uploadFunctionApp = this.uploadZippedArfifactToFunctionApp(functionApp, functionZipFile); + const uploadBlobStorage = await this.uploadZippedArtifactToBlobStorage(functionZipFile); + await Promise.all([uploadFunctionApp, uploadBlobStorage]); } /** @@ -139,16 +139,16 @@ export class FunctionAppService extends BaseService { return await this.get(); } - private async uploadZippedArfifactToFunctionApp(functionApp) { + public async uploadZippedArfifactToFunctionApp(functionApp: Site, functionZipFile: string) { const scmDomain = this.getScmDomain(functionApp); this.log(`Deploying zip file to function app: ${functionApp.name}`); - if (!(this.functionZipFile && fs.existsSync(this.functionZipFile))) { + if (!(functionZipFile && fs.existsSync(functionZipFile))) { throw new Error("No zip file found for function app"); } - this.log(`-> Deploying service package @ ${this.functionZipFile}`); + this.log(`-> Deploying service package @ ${functionZipFile}`); // https://github.com/projectkudu/kudu/wiki/Deploying-from-a-zip-file-or-url const requestOptions = { @@ -162,7 +162,7 @@ export class FunctionAppService extends BaseService { } }; - await this.sendFile(requestOptions, this.functionZipFile); + await this.sendFile(requestOptions, functionZipFile); this.log("-> Function package uploaded successfully"); const serverlessFunctions = this.serverless.service.getAllFunctions(); @@ -185,11 +185,11 @@ export class FunctionAppService extends BaseService { /** * Uploads artifact file to blob storage container */ - private async uploadZippedArtifactToBlobStorage() { + private async uploadZippedArtifactToBlobStorage(functionZipFile: string) { await this.blobService.initialize(); await this.blobService.createContainerIfNotExists(this.deploymentConfig.container); await this.blobService.uploadFile( - this.functionZipFile, + functionZipFile, this.deploymentConfig.container, this.getArtifactName(this.deploymentName), ); diff --git a/src/services/resourceService.test.ts b/src/services/resourceService.test.ts index 3b757547..bd223149 100644 --- a/src/services/resourceService.test.ts +++ b/src/services/resourceService.test.ts @@ -8,6 +8,7 @@ import { Utils } from "../shared/utils"; describe("Resource Service", () => { const deployments = MockFactory.createTestDeployments(); + const template = "myTemplate"; beforeAll(() => { ResourceManagementClient.prototype.resourceGroups = { @@ -18,6 +19,7 @@ describe("Resource Service", () => { ResourceManagementClient.prototype.deployments = { deleteMethod: jest.fn(), listByResourceGroup: jest.fn(() => Promise.resolve(deployments)), + exportTemplate: jest.fn(() => Promise.resolve(template)), } as any; }); @@ -88,4 +90,21 @@ describe("Resource Service", () => { const deps = await service.getDeployments(); expect(deps).toEqual(deployments); }); + + it("gets deployment template",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 deploymentName = "myDeployment"; + const result = await service.getDeploymentTemplate(deploymentName); + expect(ResourceManagementClient.prototype.deployments.exportTemplate) + .toBeCalledWith( + resourceGroup, + deploymentName + ); + expect(result).toEqual(template); + }); }); \ No newline at end of file diff --git a/src/services/resourceService.ts b/src/services/resourceService.ts index 2a9baa29..4b7dca09 100644 --- a/src/services/resourceService.ts +++ b/src/services/resourceService.ts @@ -12,11 +12,22 @@ export class ResourceService extends BaseService { this.resourceClient = new ResourceManagementClient(this.credentials, this.subscriptionId); } + /** + * Get all deployments for resource group + */ public async getDeployments() { this.log(`Listing deployments for resource group '${this.resourceGroup}':`); return await this.resourceClient.deployments.listByResourceGroup(this.resourceGroup); } + /** + * Get ARM template for previous deployment + * @param deploymentName Name of deployment + */ + public async getDeploymentTemplate(deploymentName: string) { + return await this.resourceClient.deployments.exportTemplate(this.resourceGroup, deploymentName); + } + public async deployResourceGroup() { this.log(`Creating resource group: ${this.resourceGroup}`); diff --git a/src/services/rollbackService.test.ts b/src/services/rollbackService.test.ts new file mode 100644 index 00000000..2b0e51da --- /dev/null +++ b/src/services/rollbackService.test.ts @@ -0,0 +1,130 @@ +import mockFs from "mock-fs"; +import path from "path"; +import Serverless from "serverless"; +import { ArmDeployment } from "../models/armTemplates"; +import { DeploymentConfig } from "../models/serverless"; +import { MockFactory } from "../test/mockFactory"; +import { RollbackService } from "./rollbackService"; + +jest.mock("./azureBlobStorageService"); +import { AzureBlobStorageService } from "./azureBlobStorageService"; + +jest.mock("./resourceService"); +import { ResourceService } from "./resourceService"; + +jest.mock("./functionAppService") +import { FunctionAppService } from "./functionAppService"; + +jest.mock("./armService"); +import { ArmService } from "./armService"; + +describe("Rollback Service", () => { + + const template = MockFactory.createTestArmTemplate(); + const parameters = MockFactory.createTestParameters(); + for (const p of Object.keys(parameters)) { + parameters[p] = parameters[p].value + } + const appStub = "appStub"; + const sasURL = "sasURL"; + const containerName = "deployment-artifacts"; + const artifactName = MockFactory.createTestDeployment().name.replace("deployment", "artifact") + ".zip"; + const artifactPath = `.serverless${path.sep}${artifactName}` + const armDeployment: ArmDeployment = { template, parameters }; + + function createOptions(timestamp?: string): Serverless.Options { + return { + ...MockFactory.createTestServerlessOptions(), + timestamp: timestamp || MockFactory.createTestTimestamp(), + } as any + } + + function createService(sls?: Serverless, options?: Serverless.Options): RollbackService { + return new RollbackService( + sls || MockFactory.createTestServerless(), + options || createOptions(), + ) + } + + beforeEach(() => { + // Mocking the file system so that files are not created in project directory + mockFs({}) + ResourceService.prototype.getDeployments = jest.fn(() => Promise.resolve( + [ + ...MockFactory.createTestDeployments(5, true), + MockFactory.createTestDeployment("noTimestamp") + ] + )) as any; + ResourceService.prototype.getDeploymentTemplate = jest.fn( + () => Promise.resolve({ template }) + ) as any; + AzureBlobStorageService.prototype.generateBlobSasTokenUrl = jest.fn(() => sasURL) as any; + FunctionAppService.prototype.get = jest.fn(() => appStub) as any; + }); + + afterEach(() => { + mockFs.restore(); + jest.resetAllMocks(); + }); + + it("should return early with no timestamp", async () => { + const sls = MockFactory.createTestServerless(); + const options = {} as any; + const service = createService(sls, options); + await service.rollback(); + const calls = (sls.cli.log as any).mock.calls; + expect(calls[0][0]).toEqual("Need to specify a timestamp for rollback. Run `sls deploy list` to see timestamps of deployments"); + }); + + it("should return early with invalid timestamp", async () => { + const sls = MockFactory.createTestServerless(); + const timestamp = "garbage"; + const options = createOptions(timestamp) + const service = createService(sls, options); + await service.rollback(); + const calls = (sls.cli.log as any).mock.calls; + expect(calls[0][0]).toEqual(`Couldn't find deployment with timestamp: ${timestamp}`); + }); + + it("should deploy blob package directly to function app", async () => { + const service = createService(); + await service.rollback(); + expect(AzureBlobStorageService.prototype.initialize).toBeCalled(); + expect(ArmService.prototype.deployTemplate).toBeCalledWith(armDeployment); + expect(AzureBlobStorageService.prototype.downloadBinary).toBeCalledWith( + containerName, + artifactName, + artifactPath, + ); + expect(FunctionAppService.prototype.get).toBeCalled(); + expect(FunctionAppService.prototype.uploadZippedArfifactToFunctionApp).toBeCalledWith( + appStub, + artifactPath + ) + }); + + it("should deploy function app with SAS URL", async () => { + const sls = MockFactory.createTestServerless(); + const deploymentConfig: DeploymentConfig = { + runFromBlobUrl: true + } + sls["deploy"] = deploymentConfig; + const service = createService(sls); + await service.rollback(); + expect(AzureBlobStorageService.prototype.initialize).toBeCalled(); + expect(ArmService.prototype.deployTemplate).toBeCalledWith({ + ...armDeployment, + parameters: { + ...armDeployment.parameters, + functionAppRunFromPackage: sasURL, + } + }); + expect(AzureBlobStorageService.prototype.downloadBinary).not.toBeCalled(); + expect(AzureBlobStorageService.prototype.generateBlobSasTokenUrl).toBeCalledWith( + containerName, + artifactName + ); + expect(FunctionAppService.prototype.get).not.toBeCalled(); + expect(FunctionAppService.prototype.uploadZippedArfifactToFunctionApp).not.toBeCalled(); + }); +}); diff --git a/src/services/rollbackService.ts b/src/services/rollbackService.ts new file mode 100644 index 00000000..0adb0bd0 --- /dev/null +++ b/src/services/rollbackService.ts @@ -0,0 +1,150 @@ +import { DeploymentExtended } from "@azure/arm-resources/esm/models"; +import path from "path"; +import Serverless from "serverless"; +import { Utils } from "../shared/utils"; +import { ArmService } from "./armService"; +import { AzureBlobStorageService } from "./azureBlobStorageService"; +import { BaseService } from "./baseService"; +import { FunctionAppService } from "./functionAppService"; +import { ResourceService } from "./resourceService"; +import { ArmDeployment } from "../models/armTemplates"; + +/** + * Services for the Rollback Plugin + */ +export class RollbackService extends BaseService { + private resourceService: ResourceService; + private blobService: AzureBlobStorageService; + + /** + * Initialize rollback service, including authentication and initialization + * of a `ResourceService` + * @param serverless Serverless object + * @param options Serverless CLI options + */ + public constructor(serverless: Serverless, options: Serverless.Options) { + super(serverless, options); + this.resourceService = new ResourceService(serverless, options); + this.blobService = new AzureBlobStorageService(serverless, options); + } + + /** + * Rolls back the function app. If `timestamp` present in `options`, + * function app will be rolled back directly to that timestamp. Otherwise, + * function app will be rolled back to the deployment previous to the most recent + */ + public async rollback() { + // Get deployment as specified by timestamp + const deployment = await this.getDeployment(); + if (!deployment) { + return; + } + // Name of artifact in blob storage + const artifactName = this.getArtifactName(deployment.name); + // Redeploy resource group (includes SAS token URL if running from blob URL) + await this.redeployDeployment(deployment, artifactName); + } + + /** + * Re-deploy a previous deployment of a resource group + * @param deployment Previous deployment retrieved from Azure SDK + * @param artifactName Name of zipped artifact in blob storage associated with deployment + */ + private async redeployDeployment(deployment: DeploymentExtended, artifactName: string) { + const armService = new ArmService(this.serverless, this.options); + const armDeployment = await this.convertToArmDeployment(deployment); + // Initialize blob service for either creating SAS token or downloading artifact to uplod to function app + await this.blobService.initialize(); + if (this.deploymentConfig.runFromBlobUrl) { + // Set functionRunFromPackage param to SAS URL of blob + armDeployment.parameters.functionAppRunFromPackage = await this.blobService.generateBlobSasTokenUrl( + this.deploymentConfig.container, + artifactName + ) + } + await armService.deployTemplate(armDeployment); + /** + * Cannot use an `else` statement just because deploying the artifact + * depends on `deployTemplate` already being called + */ + if (!this.deploymentConfig.runFromBlobUrl) { + const artifactPath = await this.downloadArtifact(artifactName); + await this.redeployArtifact(artifactPath); + } + } + + /** + * Convert previous deployment to ArmDeployment + * @param deployment Previous deployment retrieved from Azure SDK + */ + private async convertToArmDeployment(deployment: DeploymentExtended): Promise { + const resourceService = new ResourceService(this.serverless, this.options); + const { template } = await resourceService.getDeploymentTemplate(deployment.name); + const { parameters } = deployment.properties; + for (const key of Object.keys(parameters)) { + parameters[key] = parameters[key].value; + } + return { + template, + parameters, + } + } + + /** + * Deploy zipped artifact to function app + * @param artifactPath Path to downloaded zipped artifact + */ + private async redeployArtifact(artifactPath: string) { + const functionAppService = new FunctionAppService(this.serverless, this.options); + const functionApp = await functionAppService.get(); + await functionAppService.uploadZippedArfifactToFunctionApp(functionApp, artifactPath); + } + + /** + * Get deployment specified by timestamp in Serverless options + */ + private async getDeployment(): Promise { + let timestamp = Utils.get(this.options, "timestamp"); + if (!timestamp) { + this.log("Need to specify a timestamp for rollback. Run `sls deploy list` to see timestamps of deployments"); + this.log("Example usage:\n\nsls rollback -t 1562014362"); + return null; + } + const deployments = await this.getArmDeploymentsByTimestamp(); + const deployment = deployments.get(timestamp); + if (!deployment) { + this.log(`Couldn't find deployment with timestamp: ${timestamp}`); + this.log(`Timestamps: ${Array.from(deployments.keys()).map((key) => `\n${key}`)}`) + } + return deployment; + } + + /** + * Download zipped function app artifact from blob storage corresponding to the specified deployment + * @param artifactName Name of artifact to download + */ + private async downloadArtifact(artifactName: string): Promise { + const artifactPath = path.join(this.serverless.config.servicePath, ".serverless", artifactName) + await this.blobService.downloadBinary( + this.deploymentConfig.container, + artifactName, + artifactPath + ); + return artifactPath; + } + + /** + * Get all deployments of a resource group indexed by Unix timestamp string + */ + private async getArmDeploymentsByTimestamp(): Promise> { + const result = new Map(); + const armDeployments = await this.resourceService.getDeployments(); + for (const armDeployment of armDeployments) { + const timestamp = Utils.getTimestampFromName(armDeployment.name); + if (timestamp) { + result.set(timestamp, armDeployment); + } + } + return result; + } +} diff --git a/src/shared/utils.test.ts b/src/shared/utils.test.ts index 6982004e..3e6feda9 100644 --- a/src/shared/utils.test.ts +++ b/src/shared/utils.test.ts @@ -162,4 +162,12 @@ describe("utils", () => { const actual = Utils.getNormalizedRegionName(expected); expect(actual).toEqual(expected); }); + it("should get a timestamp from a name", () => { + expect(Utils.getTimestampFromName("myDeployment-t12345")).toEqual("12345"); + expect(Utils.getTimestampFromName("myDeployment-t678987645")).toEqual("678987645"); + expect(Utils.getTimestampFromName("-t12345")).toEqual("12345"); + + expect(Utils.getTimestampFromName("myDeployment-t")).toEqual(null); + expect(Utils.getTimestampFromName("")).toEqual(null); + }) }); diff --git a/src/shared/utils.ts b/src/shared/utils.ts index 974d865c..8bc5e701 100644 --- a/src/shared/utils.ts +++ b/src/shared/utils.ts @@ -203,4 +203,20 @@ export class Utils { }) .join(""); } + + public static get(object: any, key: string, defaultValue?: any) { + if (key in object) { + return object[key]; + } + return defaultValue + } + + public static getTimestampFromName(name: string): string { + const regex = /.*-t([0-9]+)/; + const match = name.match(regex); + if (!match || match.length < 2) { + return null; + } + return match[1]; + } } diff --git a/src/test/mockFactory.ts b/src/test/mockFactory.ts index ba015c60..485b689a 100644 --- a/src/test/mockFactory.ts +++ b/src/test/mockFactory.ts @@ -1,6 +1,6 @@ import { ApiContract, ApiManagementServiceResource } from "@azure/arm-apimanagement/esm/models"; import { FunctionEnvelope, Site } from "@azure/arm-appservice/esm/models"; -import { DeploymentsListByResourceGroupResponse } from "@azure/arm-resources/esm/models"; +import { DeploymentExtended, 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"; @@ -12,7 +12,7 @@ import Service from "serverless/classes/Service"; import Utils from "serverless/classes/Utils"; import PluginManager from "serverless/lib/classes/PluginManager"; import { ApiCorsPolicy, ApiManagementConfig } from "../models/apiManagement"; -import { ArmResourceTemplate } from "../models/armTemplates"; +import { ArmDeployment, ArmResourceTemplate } from "../models/armTemplates"; import { ServicePrincipalEnvVariables } from "../models/azureProvider"; import { Logger } from "../models/generic"; import { ServerlessAzureConfig, ServerlessAzureProvider } from "../models/serverless"; @@ -143,19 +143,38 @@ export class MockFactory { return credentials; } - public static createTestDeployments(count: number = 5): DeploymentsListByResourceGroupResponse { + public static createTestTimestamp(): string { + return "1562184492"; + } + + public static createTestDeployments(count: number = 5, includeTimestamp = false): DeploymentsListByResourceGroupResponse { const result = []; + const originalTimestamp = +MockFactory.createTestTimestamp(); for (let i = 0; i < count; i++) { - result.push({ - name: `deployment${i + 1}`, - properties: { - timestamp: new Date(), - } - }) + result.push( + MockFactory.createTestDeployment((includeTimestamp) ? `deployment${i + 1}-t${originalTimestamp + i}` : `deployment${i + 1}`) + ) } return result as DeploymentsListByResourceGroupResponse } + public static createTestParameters() { + return { + param1: { value: "1", type: "String" }, + param2: { value: "2", type: "String" }, + } + } + + public static createTestDeployment(name?: string): DeploymentExtended { + return { + name: name || `deployment1-t${MockFactory.createTestTimestamp()}`, + properties: { + timestamp: new Date(), + parameters: MockFactory.createTestParameters(), + } + } + } + public static createTestAxiosResponse( config: AxiosRequestConfig, responseJson: T, @@ -195,11 +214,15 @@ export class MockFactory { return { containerItems: result } as ServiceListContainersSegmentResponse; } - public static createTestBlockBlobUrl(containerName: string, blobName: string) { + public static createTestBlockBlobUrl(containerName: string, blobName: string, contents: string = "test") { return { containerName, blobName, + url: `http://storage.azure.com/${containerName}/${blobName}`, delete: jest.fn(), + getProperties: jest.fn(() => Promise.resolve({ + contentLength: contents.length + })) } } @@ -489,6 +512,13 @@ export class MockFactory { }; } + public static createTestArmDeployment(): ArmDeployment { + return { + template: MockFactory.createTestArmTemplate(), + parameters: MockFactory.createTestParameters(), + } + } + public static createTestArmTemplate(): ArmResourceTemplate { return { "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#",