From d11ed8068bcf8a46f7b30514b2567cd66d87299a Mon Sep 17 00:00:00 2001 From: Neeraj Mandal Date: Mon, 8 Jul 2019 12:43:50 -0700 Subject: [PATCH 1/4] feat: Invoke Plugin --- .travis.yml | 5 +- docs/DEPLOY.md | 47 +++++- docs/ROLLBACK.md | 37 +++++ docs/sequenceDiagrams/deployBlob.md | 17 +++ docs/sequenceDiagrams/deployBlob.png | Bin 0 -> 40699 bytes docs/sequenceDiagrams/rollback.md | 20 +++ docs/sequenceDiagrams/rollback.png | Bin 0 -> 48105 bytes package-lock.json | 67 +++++++-- package.json | 6 +- src/armTemplates/resources/storageAccount.ts | 22 ++- src/config.ts | 6 +- src/models/serverless.ts | 20 +++ src/plugins/deploy/azureDeployPlugin.ts | 3 +- src/plugins/invoke/azureInvoke.test.ts | 97 ++++++++++++ src/plugins/invoke/azureInvoke.ts | 67 ++++++--- src/plugins/login/loginPlugin.test.ts | 3 +- src/plugins/login/loginPlugin.ts | 1 + src/services/armService.test.ts | 6 +- src/services/armService.ts | 7 +- src/services/azureBlobStorageService.test.ts | 62 +++++++- src/services/azureBlobStorageService.ts | 150 ++++++++++++++++--- src/services/baseService.test.ts | 3 +- src/services/baseService.ts | 63 +++++++- src/services/functionAppService.test.ts | 21 ++- src/services/functionAppService.ts | 110 +++++++------- src/services/invokeService.test.ts | 73 +++++++++ src/services/invokeService.ts | 86 +++++++++++ src/services/loginService.test.ts | 3 +- src/services/loginService.ts | 22 ++- src/services/packageService.ts | 6 +- src/services/resourceService.test.ts | 6 +- src/shared/utils.test.ts | 16 +- src/shared/utils.ts | 15 +- src/test/mockFactory.ts | 15 +- 34 files changed, 936 insertions(+), 146 deletions(-) create mode 100644 docs/ROLLBACK.md create mode 100644 docs/sequenceDiagrams/deployBlob.md create mode 100644 docs/sequenceDiagrams/deployBlob.png create mode 100644 docs/sequenceDiagrams/rollback.md create mode 100644 docs/sequenceDiagrams/rollback.png create mode 100644 src/plugins/invoke/azureInvoke.test.ts create mode 100644 src/services/invokeService.test.ts create mode 100644 src/services/invokeService.ts diff --git a/.travis.yml b/.travis.yml index 536eeeb5..ee489b96 100644 --- a/.travis.yml +++ b/.travis.yml @@ -27,14 +27,15 @@ install: jobs: include: - stage: test - name: "Unit Tests on Node 8" + name: "Compile & Unit Tests on Node 8" node_js: "8" script: + - npm run compile - npm run test:ci after_success: - npm run test:coverage - cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js && rm -rf ./coverage - - name: "Unit Tests on Node 10" + - name: "Compile & Unit Tests on Node 10" node_js: "10" - stage: publish diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 45fbd25f..ad19fd8d 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -1,4 +1,4 @@ -# Overview +# Deploy Overview Deploy usage guide and design decision. @@ -56,3 +56,48 @@ then user try to deploy 1. always using the right resource group 1. restrictive for user who have already defined their resources + +## Deployment Methodologies + +#### 1. Deployment to Function App (rollback disabled) +- Deploy resource group, upload packaged artifact directly to function app. Sets function app `RUN_FROM_PACKAGE` setting to `1`. + +#### 2. Deployment to Blob Storage (rollback enabled) +- Deploy resource group, upload packaged function app to blob storage with version name. Sets function app `RUN_FROM_PACKAGE` setting to path of zipped artifact in blob storage +- Default container name - `DEPLOYMENT_ARTIFACTS` (configurable in `serverless.yml`, see below) + +### Deployment configuration + +```yml +service: my-app +provider: + ... + +plugins: + - serverless-azure-functions + +package: + ... + +deploy: + # Rollback enabled, deploying to blob storage + # Default is true + # If false, deploys directly to function app + rollback: true + # Container in blob storage containing deployed packages + # Default is DEPLOYMENT_ARTIFACTS + container: MY_CONTAINER_NAME + +functions: + ... +``` + +If rollback is enabled, the name of the created package will include the UTC timestamp of its creation. This timestamp will also be included in the name of the Azure deployment so as to be able to link the two together. Both names will have `-t{timestamp}` appended to the end. + +##### Sequence diagram for deployment to blob storage + +![alt text](./sequenceDiagrams/deployBlob.png) + +##### Sub-Commands + +- `sls deploy list` - Logs list of deployments to configured resource group with relevant metadata (name, timestamp, etc.). Also logs versions of deployed function app code if available diff --git a/docs/ROLLBACK.md b/docs/ROLLBACK.md new file mode 100644 index 00000000..d9bdab13 --- /dev/null +++ b/docs/ROLLBACK.md @@ -0,0 +1,37 @@ +# Rollback + +##### `sls rollback` +- Description - Roll back deployment of Function App & Resource Group +- Options: + - `-t` or `--timestamp` - Timestamp associated with version to target + - Use `sls deploy list` to discover timestamps + - Defaults to previous deployment +- **Important to note that there is no option for rolling back an individual function. A function app is considered one unit and will be rolled back as such.** +- In order to roll back your function app, make sure your `deploy.rollback` is either set to `true` or not specified (defaults to `true`). The container in Azure Blob Storage which contains the packaged code artifacts can also be specified, defaults to `DEPLOYMENT_ARTIFACTS` (see [deploy documentation](./DEPLOY.md)) + +##### Example usage + +```bash +# List all deployments to know the timestamp for rollback +$ sls deploy list +Serverless: +----------- +Name: myFunctionApp-t1561479533 +Timestamp: 1561479533 +Datetime: 2019-06-25T16:18:53+00:00 +----------- +Name: myFunctionApp-t1561479506 +Timestamp: 1561479506 +Datetime: 2019-06-25T16:18:26+00:00 +----------- +Name: myFunctionApp-t1561479444 +Timestamp: 1561479444 +Datetime: 2019-06-25T16:17:24+00:00 +----------- + +$ sls rollback -t 1561479506 +``` + +##### Sequence diagram for rollback + +![alt text](./sequenceDiagrams/rollback.png) \ No newline at end of file diff --git a/docs/sequenceDiagrams/deployBlob.md b/docs/sequenceDiagrams/deployBlob.md new file mode 100644 index 00000000..a1e79d7f --- /dev/null +++ b/docs/sequenceDiagrams/deployBlob.md @@ -0,0 +1,17 @@ +```mermaid +sequenceDiagram + participant s as Serverless CLI + participant r as Resource Group + participant f as Function App + participant b as Blob Storage + + note right of s: `sls deploy` + s ->> r: Create resource group + s ->> r: Deploy ARM template + r ->> f: Included in ARM template + r ->> b: Included in ARM template + note right of s: Zip code + s ->> b: Deploy zip code with name {serviceName}-t{timestamp}.zip + s ->> f: Set package path in settings + note right of s: Log URLs +``` \ No newline at end of file diff --git a/docs/sequenceDiagrams/deployBlob.png b/docs/sequenceDiagrams/deployBlob.png new file mode 100644 index 0000000000000000000000000000000000000000..4593ee975bc9969c7349d9d1500eda03333be92c GIT binary patch literal 40699 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 I-v9Z30rwDdS^xk5 literal 0 HcmV?d00001 diff --git a/docs/sequenceDiagrams/rollback.md b/docs/sequenceDiagrams/rollback.md new file mode 100644 index 00000000..0dd1a328 --- /dev/null +++ b/docs/sequenceDiagrams/rollback.md @@ -0,0 +1,20 @@ + +```mermaid +sequenceDiagram + participant s as Serverless CLI + participant r as Resource Group + participant f as Function App + participant b as Blob Storage + + note right of s: `sls rollback` + s ->> r: Request deployments + r ->> s: Return deployments + note right of s: Select deployment + s ->> r: Deploy ARM template + s ->> b: Request names of previously deployed artifacts + b ->> s: Return names + note right of s: Select artifact + s ->> r: Re-deploy template + s ->> f: Update RUN_FROM_PACKAGE path (could be done with above step) + note right of s: Log URLs +``` \ No newline at end of file diff --git a/docs/sequenceDiagrams/rollback.png b/docs/sequenceDiagrams/rollback.png new file mode 100644 index 0000000000000000000000000000000000000000..938be68390daa1c1c7465233077224124f5b71bc GIT binary patch literal 48105 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 KyQCqBd4B;dpFku4 literal 0 HcmV?d00001 diff --git a/package-lock.json b/package-lock.json index e47f444a..ddb886eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "serverless-azure-functions", - "version": "1.0.0-4", + "version": "1.0.0-5", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -34,6 +34,16 @@ "tslib": "^1.9.3" } }, + "@azure/arm-storage": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@azure/arm-storage/-/arm-storage-9.0.1.tgz", + "integrity": "sha512-cMswGdhbxrct87+lFDqzlezQDXzLGBj79aMEyF1sjJ2HnuwJtEEFA8Zfjg/KbHiT7vkFAJYDQgtB4Fu1joEkrg==", + "requires": { + "@azure/ms-rest-azure-js": "^1.3.2", + "@azure/ms-rest-js": "^1.8.1", + "tslib": "^1.9.3" + } + }, "@azure/ms-rest-azure-env": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@azure/ms-rest-azure-env/-/ms-rest-azure-env-1.1.2.tgz", @@ -1784,6 +1794,7 @@ "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", @@ -1956,6 +1967,7 @@ "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", @@ -2425,7 +2437,8 @@ "bn.js": { "version": "4.11.8", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz", - "integrity": "sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA==" + "integrity": "sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA==", + "optional": true }, "body-parser": { "version": "1.19.0", @@ -2530,7 +2543,8 @@ "brorand": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", - "integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=" + "integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=", + "optional": true }, "browser-process-hrtime": { "version": "0.1.3", @@ -2559,6 +2573,7 @@ "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", @@ -2595,6 +2610,7 @@ "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" @@ -2691,7 +2707,8 @@ "buffer-xor": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz", - "integrity": "sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk=" + "integrity": "sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk=", + "optional": true }, "builtin-modules": { "version": "1.1.1", @@ -2873,6 +2890,7 @@ "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" @@ -3250,6 +3268,7 @@ "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", @@ -3262,6 +3281,7 @@ "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", @@ -3720,6 +3740,7 @@ "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", @@ -3781,6 +3802,7 @@ "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" } @@ -4160,6 +4182,7 @@ "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" @@ -4174,6 +4197,7 @@ "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" @@ -5471,6 +5495,7 @@ "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" @@ -5480,6 +5505,7 @@ "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" @@ -5489,6 +5515,7 @@ "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", @@ -5807,7 +5834,8 @@ "is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=" + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "optional": true }, "is-fullwidth-code-point": { "version": "1.0.0", @@ -7447,7 +7475,8 @@ "longest": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz", - "integrity": "sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc=" + "integrity": "sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc=", + "optional": true }, "loose-envify": { "version": "1.4.0", @@ -7538,6 +7567,7 @@ "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", @@ -7563,6 +7593,7 @@ "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" @@ -7653,12 +7684,14 @@ "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==" + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "optional": true }, "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=" + "integrity": "sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=", + "optional": true }, "minimatch": { "version": "3.0.4", @@ -8256,6 +8289,7 @@ "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,6 +8398,7 @@ "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", @@ -8566,7 +8601,8 @@ "prr": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", - "integrity": "sha1-0/wRS6BplaRexok/SEzrHXj19HY=" + "integrity": "sha1-0/wRS6BplaRexok/SEzrHXj19HY=", + "optional": true }, "pseudomap": { "version": "1.0.2", @@ -8633,6 +8669,7 @@ "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" } @@ -9045,6 +9082,7 @@ "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" @@ -9434,6 +9472,7 @@ "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" @@ -9629,7 +9668,8 @@ "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==" + "integrity": "sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==", + "optional": true }, "source-map": { "version": "0.7.3", @@ -10186,7 +10226,8 @@ "tapable": { "version": "0.2.9", "resolved": "https://registry.npmjs.org/tapable/-/tapable-0.2.9.tgz", - "integrity": "sha512-2wsvQ+4GwBvLPLWsNfLCDYGsW6xb7aeC6utq2Qh0PFwgEy7K7dsma9Jsmb2zSQj7GvYAyUGSntLtsv++GmgL1A==" + "integrity": "sha512-2wsvQ+4GwBvLPLWsNfLCDYGsW6xb7aeC6utq2Qh0PFwgEy7K7dsma9Jsmb2zSQj7GvYAyUGSntLtsv++GmgL1A==", + "optional": true }, "tar-stream": { "version": "1.6.2", @@ -11036,6 +11077,7 @@ "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" @@ -11044,7 +11086,8 @@ "source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "optional": true } } }, diff --git a/package.json b/package.json index 80c7824c..2d65b3f3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "serverless-azure-functions", - "version": "1.0.0-4", + "version": "1.0.0-5", "description": "Provider plugin for the Serverless Framework v1.x which adds support for Azure Functions.", "license": "MIT", "main": "./lib/index.js", @@ -14,8 +14,9 @@ "test": "jest", "test:ci": "npm run test -- --ci", "test:coverage": "npm run test -- --coverage", + "compile": "tsc", "prebuild": "shx rm -rf lib/ && npm run test", - "build": "tsc" + "build": "npm run compile" }, "repository": { "git": "https://github.com/serverless/serverless-azure-functions" @@ -38,6 +39,7 @@ "@azure/arm-apimanagement": "^5.1.0", "@azure/arm-appservice": "^5.7.0", "@azure/arm-resources": "^1.0.1", + "@azure/arm-storage": "^9.0.1", "@azure/ms-rest-nodeauth": "^1.0.1", "@azure/storage-blob": "^10.3.0", "axios": "^0.18.0", diff --git a/src/armTemplates/resources/storageAccount.ts b/src/armTemplates/resources/storageAccount.ts index b786e983..0c59f8bc 100644 --- a/src/armTemplates/resources/storageAccount.ts +++ b/src/armTemplates/resources/storageAccount.ts @@ -1,11 +1,12 @@ import { ArmResourceTemplateGenerator, ArmResourceTemplate } from "../../models/armTemplates"; import { ServerlessAzureConfig, ResourceConfig } from "../../models/serverless"; +import { Utils } from "../../shared/utils"; export class StorageAccountResource implements ArmResourceTemplateGenerator { public static getResourceName(config: ServerlessAzureConfig) { return config.provider.storageAccount && config.provider.storageAccount.name ? config.provider.storageAccount.name - : `${config.provider.prefix}${config.provider.region.substr(0, 3)}${config.provider.stage.substr(0, 3)}sa`.replace("-", "").toLocaleLowerCase(); + : StorageAccountResource.getDefaultStorageAccountName(config) } public getTemplate(): ArmResourceTemplate { @@ -62,4 +63,23 @@ export class StorageAccountResource implements ArmResourceTemplateGenerator { storageAccoutSkuTier: resourceConfig.sku.tier, }; } + + /** + * Gets a default storage account name. + * Storage account names can have at most 24 characters and can have only alpha-numerics + * Default naming convention: + * + * "(first 3 of prefix)(first 3 of region)(first 3 of stage)(first 12 of service)sa" + * (Maximum of 23 characters) + * @param config Serverless Azure Config + */ + private static getDefaultStorageAccountName(config: ServerlessAzureConfig): string { + const prefix = Utils.appendSubstrings( + 3, + config.provider.prefix, + config.provider.region, + config.provider.stage, + ); + return `${prefix}${config.service.substr(0, 12)}sa`.replace("-", "").toLocaleLowerCase(); + } } \ No newline at end of file diff --git a/src/config.ts b/src/config.ts index f2c36435..f24db2d1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,5 +1,6 @@ -export const constants = { +export const configConstants = { bearer: "Bearer ", + deploymentArtifactContainer: "deployment-artifacts", functionAppApiPath: "/api/", functionAppDomain: ".azurewebsites.net", functionsAdminApiPath: "/admin/functions/", @@ -10,10 +11,11 @@ export const constants = { 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/", scmZipDeployApiPath: "/api/zipdeploy" }; -export default constants; \ No newline at end of file +export default configConstants; \ No newline at end of file diff --git a/src/models/serverless.ts b/src/models/serverless.ts index b3f6d315..8f34c999 100644 --- a/src/models/serverless.ts +++ b/src/models/serverless.ts @@ -23,6 +23,10 @@ export interface FunctionAppConfig extends ResourceConfig { extensionVersion?; } +export interface DeploymentConfig { + rollback?: boolean; + container?: string; +} export interface ServerlessAzureConfig { service: string; provider: { @@ -47,3 +51,19 @@ export interface ServerlessAzureConfig { plugins: string[]; functions: any; } + +export interface ServerlessCommand { + usage: string; + lifecycleEvents: string[]; + options?: { + [key: string]: { + usage: string; + shortcut?: string; + }; + }; + commands?: ServerlessCommandMap; +} + +export interface ServerlessCommandMap { + [command: string]: ServerlessCommand; +} \ No newline at end of file diff --git a/src/plugins/deploy/azureDeployPlugin.ts b/src/plugins/deploy/azureDeployPlugin.ts index 485e771a..40362ed6 100644 --- a/src/plugins/deploy/azureDeployPlugin.ts +++ b/src/plugins/deploy/azureDeployPlugin.ts @@ -1,6 +1,6 @@ import Serverless from "serverless"; -import { ResourceService } from "../../services/resourceService"; import { FunctionAppService } from "../../services/functionAppService"; +import { ResourceService } from "../../services/resourceService"; export class AzureDeployPlugin { public hooks: { [eventName: string]: Promise }; @@ -48,6 +48,7 @@ export class AzureDeployPlugin { private async deploy() { const resourceService = new ResourceService(this.serverless, this.options); + await resourceService.deployResourceGroup(); const functionAppService = new FunctionAppService(this.serverless, this.options); diff --git a/src/plugins/invoke/azureInvoke.test.ts b/src/plugins/invoke/azureInvoke.test.ts new file mode 100644 index 00000000..982f562d --- /dev/null +++ b/src/plugins/invoke/azureInvoke.test.ts @@ -0,0 +1,97 @@ +import { MockFactory } from "../../test/mockFactory"; +import { invokeHook } from "../../test/utils"; +import mockFs from "mock-fs"; +import { AzureInvoke } from "./azureInvoke"; +jest.mock("../../services/functionAppService"); +jest.mock("../../services/resourceService"); +jest.mock("../../services/invokeService"); +import { InvokeService } from "../../services/invokeService"; + +describe("Azure Invoke Plugin", () => { + const fileContent = JSON.stringify({ + name: "Azure-Test", + }); + afterEach(() => { + jest.resetAllMocks(); + }) + + beforeAll(() => { + mockFs({ + "testFile.json": fileContent, + }, { createCwd: true, createTmp: true }); + }); + afterAll(() => { + mockFs.restore(); + }); + + it("calls invoke hook", async () => { + const expectedResult = { data: "test" }; + const invoke = jest.fn(() => expectedResult); + InvokeService.prototype.invoke = invoke as any; + + const sls = MockFactory.createTestServerless(); + const options = MockFactory.createTestServerlessOptions(); + options["function"] = "testApp"; + options["data"] = "{\"name\": \"AzureTest\"}"; + options["method"] = "GET"; + + const plugin = new AzureInvoke(sls, options); + await invokeHook(plugin, "invoke:invoke"); + expect(invoke).toBeCalledWith(options["method"], options["function"], options["data"]); + expect(sls.cli.log).toBeCalledWith(JSON.stringify(expectedResult.data)); + }); + + it("calls the invoke hook with file path", async () => { + const expectedResult = { data: "test" }; + const invoke = jest.fn(() => expectedResult); + InvokeService.prototype.invoke = invoke as any; + const sls = MockFactory.createTestServerless(); + const options = MockFactory.createTestServerlessOptions(); + options["function"] = "testApp"; + options["path"] = "testFile.json"; + options["method"] = "GET"; + const plugin = new AzureInvoke(sls, options); + await invokeHook(plugin, "invoke:invoke"); + expect(invoke).toBeCalledWith(options["method"], options["function"], fileContent); + expect(sls.cli.log).toBeCalledWith(JSON.stringify(expectedResult.data)); + + }); + + it("calls the invoke hook with file path", async () => { + const invoke = jest.fn(); + InvokeService.prototype.invoke = invoke; + const sls = MockFactory.createTestServerless(); + const options = MockFactory.createTestServerlessOptions(); + options["function"] = "testApp"; + options["path"] = "notExist.json"; + options["method"] = "GET"; + expect(() => new AzureInvoke(sls, options)).toThrow(); + }); + + it("Function invoked with no data", async () => { + const expectedResult = { data: "test" }; + const invoke = jest.fn(() => expectedResult); + InvokeService.prototype.invoke = invoke as any; + const sls = MockFactory.createTestServerless(); + const options = MockFactory.createTestServerlessOptions(); + options["function"] = "testApp"; + options["method"] = "GET"; + const plugin = new AzureInvoke(sls, options); + await invokeHook(plugin, "invoke:invoke"); + expect(invoke).toBeCalledWith(options["method"], options["function"], undefined); + expect(sls.cli.log).toBeCalledWith(JSON.stringify(expectedResult.data)); + }); + + it("The invoke function fails when no function name is passed", async () => { + const invoke = jest.fn(); + InvokeService.prototype.invoke = invoke; + const sls = MockFactory.createTestServerless(); + const options = MockFactory.createTestServerlessOptions(); + options["function"] = null; + options["data"] = "{\"name\": \"AzureTest\"}"; + options["method"] = "GET"; + const plugin = new AzureInvoke(sls, options); + await invokeHook(plugin, "invoke:invoke"); + expect(invoke).not.toBeCalled(); + }); +}); diff --git a/src/plugins/invoke/azureInvoke.ts b/src/plugins/invoke/azureInvoke.ts index f8da7e26..f5fa5763 100644 --- a/src/plugins/invoke/azureInvoke.ts +++ b/src/plugins/invoke/azureInvoke.ts @@ -1,41 +1,72 @@ +import { isAbsolute, join } from "path"; import Serverless from "serverless"; -import { join, isAbsolute } from "path"; -import AzureProvider from "../../provider/azureProvider"; +import { InvokeService } from "../../services/invokeService"; +import fs from "fs"; +import { ServerlessCommandMap } from "../../models/serverless"; export class AzureInvoke { public hooks: { [eventName: string]: Promise }; - private provider: AzureProvider; - + private commands: ServerlessCommandMap; + private invokeService: InvokeService; public constructor(private serverless: Serverless, private options: Serverless.Options) { - this.provider = (this.serverless.getProvider("azure") as any) as AzureProvider; const path = this.options["path"]; - + if (path) { const absolutePath = isAbsolute(path) ? path : join(this.serverless.config.servicePath, path); + this.serverless.cli.log(this.serverless.config.servicePath); + this.serverless.cli.log(path); - if (!this.serverless.utils.fileExistsSync(absolutePath)) { + if (!fs.existsSync(absolutePath)) { throw new Error("The file you provided does not exist."); } - this.options["data"] = this.serverless.utils.readFileSync(absolutePath); + this.options["data"] = fs.readFileSync(absolutePath).toString(); + } + + this.commands = { + invoke: { + usage: "Invoke command", + lifecycleEvents: ["invoke"], + options: { + function: { + usage: "Function to call", + shortcut: "f", + }, + path: { + usage: "Path to file to put in body", + shortcut: "p" + }, + data: { + usage: "Data string for body of request", + shortcut: "d" + }, + method: { + usage: "HTTP method (Default is GET)", + shortcut: "m" + } + } + } } this.hooks = { - "before:invoke:invoke": this.provider.getAdminKey.bind(this), "invoke:invoke": this.invoke.bind(this) }; } - + private async invoke() { - const func = this.options.function; - const functionObject = this.serverless.service.getFunction(func); - const eventType = Object.keys(functionObject["events"][0])[0]; - - if (!this.options["data"]) { - this.options["data"] = {}; + const functionName = this.options["function"]; + const data = this.options["data"]; + const method = this.options["method"] || "GET"; + if (!functionName) { + this.serverless.cli.log("Need to provide a name of function to invoke"); + return; } - return this.provider.invoke(func, eventType, this.options["data"]); + this.invokeService = new InvokeService(this.serverless, this.options); + const response = await this.invokeService.invoke(method, functionName, data); + if(response){ + this.serverless.cli.log(JSON.stringify(response.data)); + } } -} +} \ No newline at end of file diff --git a/src/plugins/login/loginPlugin.test.ts b/src/plugins/login/loginPlugin.test.ts index 6bf138ed..fe1fa892 100644 --- a/src/plugins/login/loginPlugin.test.ts +++ b/src/plugins/login/loginPlugin.test.ts @@ -60,7 +60,8 @@ describe("Login Plugin", () => { expect(AzureLoginService.servicePrincipalLogin).toBeCalledWith( "azureServicePrincipalClientId", "azureServicePrincipalPassword", - "azureServicePrincipalTenantId" + "azureServicePrincipalTenantId", + undefined // would be options ) expect(AzureLoginService.interactiveLogin).not.toBeCalled(); expect(sls.variables["azureCredentials"]).toEqual(credentials); diff --git a/src/plugins/login/loginPlugin.ts b/src/plugins/login/loginPlugin.ts index 083831f6..ce9cf050 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:invoke:invoke": this.login.bind(this), }; } diff --git a/src/services/armService.test.ts b/src/services/armService.test.ts index b61c48cf..5ff5caca 100644 --- a/src/services/armService.test.ts +++ b/src/services/armService.test.ts @@ -182,6 +182,7 @@ describe("Arm Service", () => { const expectedResourceGroup = sls.service.provider["resourceGroup"]; const expectedDeploymentName = sls.service.provider["deploymentName"] || `${this.resourceGroup}-deployment`; + const expectedDeploymentNameRegex = new RegExp(expectedDeploymentName + "-t([0-9]+)") const expectedDeployment: Deployment = { properties: { mode: "Incremental", @@ -190,7 +191,10 @@ describe("Arm Service", () => { }, }; - expect(Deployments.prototype.createOrUpdate).toBeCalledWith(expectedResourceGroup, expectedDeploymentName, expectedDeployment); + const call = (Deployments.prototype.createOrUpdate as any).mock.calls[0]; + expect(call[0]).toEqual(expectedResourceGroup); + expect(call[1]).toMatch(expectedDeploymentNameRegex); + expect(call[2]).toEqual(expectedDeployment); }); }); }); \ No newline at end of file diff --git a/src/services/armService.ts b/src/services/armService.ts index ce13d0c3..90feef5c 100644 --- a/src/services/armService.ts +++ b/src/services/armService.ts @@ -111,9 +111,12 @@ export class ArmService extends BaseService { }; // Deploy ARM template - this.serverless.cli.log("-> Deploying ARM template..."); + this.log("-> Deploying ARM template..."); + this.log(`---> Resource Group: ${this.resourceGroup}`) + this.log(`---> Deployment Name: ${this.deploymentName}`) + const result = await this.resourceClient.deployments.createOrUpdate(this.resourceGroup, this.deploymentName, armDeployment); - this.serverless.cli.log("-> ARM deployment complete"); + this.log("-> ARM deployment complete"); return result; } diff --git a/src/services/azureBlobStorageService.test.ts b/src/services/azureBlobStorageService.test.ts index 9302e146..d4d1a10a 100644 --- a/src/services/azureBlobStorageService.test.ts +++ b/src/services/azureBlobStorageService.test.ts @@ -1,9 +1,17 @@ -import { MockFactory } from "../test/mockFactory" import mockFs from "mock-fs"; +import { MockFactory } from "../test/mockFactory"; +import { AzureBlobStorageService, AzureStorageAuthType } from "./azureBlobStorageService"; jest.mock("@azure/storage-blob"); -import { BlockBlobURL, ContainerURL, ServiceURL, Aborter, uploadFileToBlockBlob } from "@azure/storage-blob"; -import { AzureBlobStorageService } from "./azureBlobStorageService"; +jest.genMockFromModule("@azure/storage-blob") +import { Aborter, BlockBlobURL, ContainerURL, ServiceURL, uploadFileToBlockBlob, TokenCredential, SharedKeyCredential } from "@azure/storage-blob"; + +jest.mock("@azure/arm-storage") +jest.genMockFromModule("@azure/arm-storage"); +import { StorageAccounts, StorageManagementClientContext } from "@azure/arm-storage" + +jest.mock("./loginService"); +import { AzureLoginService } from "./loginService" describe("Azure Blob Storage Service", () => { @@ -14,13 +22,38 @@ describe("Azure Blob Storage Service", () => { const containers = MockFactory.createTestAzureContainers(); const sls = MockFactory.createTestServerless(); + const accountName = "slswesdevservicenamesa"; const options = MockFactory.createTestServerlessOptions(); const blockBlobUrl = MockFactory.createTestBlockBlobUrl(containerName, filePath); let service: AzureBlobStorageService; + const token = "myToken"; + const keyValue = "keyValue"; beforeAll(() => { + (SharedKeyCredential as any).mockImplementation(); + (TokenCredential as any).mockImplementation(); + + StorageAccounts.prototype.listKeys = jest.fn(() => { + return { + keys: [ + { + value: keyValue + } + ] + } + }) as any; + BlockBlobURL.fromContainerURL = jest.fn(() => blockBlobUrl) as any; + AzureLoginService.login = jest.fn(() => Promise.resolve({ + credentials: { + getToken: jest.fn(() => { + return { + accessToken: token + } + }) + } + } as any)); }); beforeAll(() => { @@ -33,12 +66,29 @@ describe("Azure Blob Storage Service", () => { mockFs.restore(); }); - beforeEach(() => { + beforeEach( async () => { service = new AzureBlobStorageService(sls, options); + await service.initialize(); + }); + + it("should initialize authentication", async () => { + // Note: initialize called in beforeEach + expect(SharedKeyCredential).toBeCalledWith(accountName, keyValue); + expect(StorageManagementClientContext).toBeCalled(); + expect(StorageAccounts).toBeCalled(); + + const tokenService = new AzureBlobStorageService(sls, options, AzureStorageAuthType.Token); + await tokenService.initialize(); + expect(TokenCredential).toBeCalled(); + }); + + it("should initialize authentication", async () => { + await service.initialize(); + expect(TokenCredential).toBeCalledWith(token); }); it("should upload a file", async () => { - uploadFileToBlockBlob.prototype = jest.fn(); + uploadFileToBlockBlob.prototype = jest.fn(() => Promise.resolve()); ContainerURL.fromServiceURL = jest.fn((serviceUrl, containerName) => (containerName as any)); await service.uploadFile(filePath, containerName); expect(uploadFileToBlockBlob).toBeCalledWith( @@ -76,7 +126,7 @@ describe("Azure Blob Storage Service", () => { 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); + await service.createContainerIfNotExists(newContainerName); expect(ContainerURL.fromServiceURL).toBeCalledWith(expect.anything(), newContainerName); expect(ContainerURL.prototype.create).toBeCalledWith(Aborter.none); }); diff --git a/src/services/azureBlobStorageService.ts b/src/services/azureBlobStorageService.ts index 9946cea5..6764d2ba 100644 --- a/src/services/azureBlobStorageService.ts +++ b/src/services/azureBlobStorageService.ts @@ -1,7 +1,15 @@ -import { Aborter, BlockBlobURL, ContainerURL, ServiceURL, StorageURL, uploadFileToBlockBlob } from "@azure/storage-blob"; +import { StorageAccounts, StorageManagementClientContext } from "@azure/arm-storage"; +import { Aborter, BlobSASPermissions, BlockBlobURL, ContainerURL, generateBlobSASQueryParameters,SASProtocol, + ServiceURL, SharedKeyCredential, StorageURL, TokenCredential, uploadFileToBlockBlob } from "@azure/storage-blob"; import Serverless from "serverless"; -import { BaseService } from "./baseService"; import { Guard } from "../shared/guard"; +import { BaseService } from "./baseService"; +import { AzureLoginService } from "./loginService"; + +export enum AzureStorageAuthType { + SharedKey, + Token +} /** * Wrapper for operations on Azure Blob Storage account @@ -12,10 +20,26 @@ export class AzureBlobStorageService extends BaseService { * Account URL for Azure Blob Storage account. Depends on `storageAccountName` being set in baseService */ private accountUrl: string; + private authType: AzureStorageAuthType; + private storageCredential: SharedKeyCredential|TokenCredential; - public constructor(serverless: Serverless, options: Serverless.Options) { + public constructor(serverless: Serverless, options: Serverless.Options, + authType: AzureStorageAuthType = AzureStorageAuthType.SharedKey) { super(serverless, options); this.accountUrl = `https://${this.storageAccountName}.blob.core.windows.net`; + this.authType = authType; + } + + /** + * Initialize Blob Storage service. This creates the credentials required + * to perform any operation with the service + */ + public async initialize() { + this.storageCredential = (this.authType === AzureStorageAuthType.SharedKey) + ? + new SharedKeyCredential(this.storageAccountName, await this.getKey()) + : + new TokenCredential(await this.getToken()); } /** @@ -25,11 +49,15 @@ export class AzureBlobStorageService extends BaseService { * @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); + Guard.empty(path, "path"); + Guard.empty(containerName, "containerName"); + this.checkInitialization(); + // Use specified blob name or replace `/` in path with `-` const name = blobName || path.replace(/^.*[\\\/]/, "-"); - uploadFileToBlockBlob(Aborter.none, path, this.getBlockBlobURL(containerName, name)); + this.log(`Uploading file at '${path}' to container '${containerName}' with name '${name}'`) + await uploadFileToBlockBlob(Aborter.none, path, this.getBlockBlobURL(containerName, name)); + this.log("Finished uploading blob"); }; /** @@ -38,8 +66,10 @@ export class AzureBlobStorageService extends BaseService { * @param blobName Blob to delete */ public async deleteFile(containerName: string, blobName: string): Promise { - Guard.empty(containerName); - Guard.empty(blobName); + Guard.empty(containerName, "containerName"); + Guard.empty(blobName, "blobName"); + this.checkInitialization(); + const blockBlobUrl = await this.getBlockBlobURL(containerName, blobName) await blockBlobUrl.delete(Aborter.none); } @@ -51,6 +81,8 @@ export class AzureBlobStorageService extends BaseService { */ public async listFiles(containerName: string, ext?: string): Promise { Guard.empty(containerName, "containerName"); + this.checkInitialization(); + const result: string[] = []; let marker; const containerURL = this.getContainerURL(containerName); @@ -74,6 +106,8 @@ export class AzureBlobStorageService extends BaseService { * Lists the containers within the Azure Blob Storage account */ public async listContainers() { + this.checkInitialization(); + const result: string[] = []; let marker; do { @@ -94,10 +128,15 @@ export class AzureBlobStorageService extends BaseService { * Creates container specified in Azure Cloud Storage options * @param containerName - Name of container to create */ - public async createContainer(containerName: string): Promise { - Guard.empty(containerName); - const containerURL = this.getContainerURL(containerName); - await containerURL.create(Aborter.none); + public async createContainerIfNotExists(containerName: string): Promise { + Guard.empty(containerName, "containerName"); + this.checkInitialization(); + + const containers = await this.listContainers(); + if (!containers.find((name) => name === containerName)) { + const containerURL = this.getContainerURL(containerName); + await containerURL.create(Aborter.none); + } } /** @@ -105,16 +144,58 @@ export class AzureBlobStorageService extends BaseService { * @param containerName Name of container to delete */ public async deleteContainer(containerName: string): Promise { - Guard.empty(containerName); + Guard.empty(containerName, "containerName"); + this.checkInitialization(); + const containerUrl = await this.getContainerURL(containerName) await containerUrl.delete(Aborter.none); } + /** + * Generate URL with SAS token for a specific blob + * @param containerName Name of container containing blob + * @param blobName Name of blob to generate SAS token for + * @param days Number of days from current date until expiry of SAS token. Defaults to 1 year + */ + public async generateBlobSasTokenUrl(containerName: string, blobName: string, days: number = 365): Promise { + this.checkInitialization(); + if (this.authType !== AzureStorageAuthType.SharedKey) { + throw new Error("Need to authenticate with shared key in order to generate SAS tokens. " + + "Initialize Blob Service with SharedKey auth type"); + } + + const now = new Date(); + const endDate = new Date(now); + endDate.setDate(endDate.getDate() + days); + + const blobSas = generateBlobSASQueryParameters({ + blobName, + cacheControl: "cache-control-override", + containerName, + contentDisposition: "content-disposition-override", + contentEncoding: "content-encoding-override", + contentLanguage: "content-language-override", + contentType: "content-type-override", + expiryTime: endDate, + ipRange: { start: "0.0.0.0", end: "255.255.255.255" }, + permissions: BlobSASPermissions.parse("racwd").toString(), + protocol: SASProtocol.HTTPSandHTTP, + startTime: now, + version: "2016-05-31" + }, + this.storageCredential as SharedKeyCredential); + + const blobUrl = this.getBlockBlobURL(containerName, blobName); + return `${blobUrl.url}?${blobSas}` + } + /** * Get ServiceURL object for Azure Blob Storage Account */ private getServiceURL(): ServiceURL { - const pipeline = StorageURL.newPipeline(this.credentials); + this.checkInitialization(); + + const pipeline = StorageURL.newPipeline(this.storageCredential); const accountUrl = this.accountUrl; const serviceUrl = new ServiceURL( accountUrl, @@ -129,7 +210,9 @@ export class AzureBlobStorageService extends BaseService { * @param serviceURL Previously created ServiceURL object (will create if undefined) */ private getContainerURL(containerName: string): ContainerURL { - Guard.empty(containerName); + Guard.empty(containerName, "containerName"); + this.checkInitialization(); + return ContainerURL.fromServiceURL( this.getServiceURL(), containerName @@ -142,11 +225,44 @@ export class AzureBlobStorageService extends BaseService { * @param blobName Name of blob */ private getBlockBlobURL(containerName: string, blobName: string): BlockBlobURL { - Guard.empty(containerName); - Guard.empty(blobName); + Guard.empty(containerName, "containerName"); + Guard.empty(blobName, "blobName"); + this.checkInitialization(); + return BlockBlobURL.fromContainerURL( this.getContainerURL(containerName), blobName, ); } + + /** + * Get access token by logging in (again) with a storage-specific context + */ + private async getToken(): Promise { + const authResponse = await AzureLoginService.login({ + tokenAudience: "https://storage.azure.com/" + }); + const token = await authResponse.credentials.getToken(); + return token.accessToken; + } + + /** + * Get access key for storage account + */ + private async getKey(): Promise { + const context = new StorageManagementClientContext(this.credentials, this.subscriptionId) + const storageAccounts = new StorageAccounts(context); + const keys = await storageAccounts.listKeys(this.resourceGroup, this.storageAccountName); + return keys.keys[0].value; + } + + /** + * Ensure that the blob storage service has been initialized. If not initialized, + * the credentials will not be available for any operation + */ + private checkInitialization() { + Guard.null(this.storageCredential, "storageCredential", + "Azure Blob Storage Service has not been initialized. Make sure .initialize() has been called " + + "before performing any operation"); + } } diff --git a/src/services/baseService.test.ts b/src/services/baseService.test.ts index f4c83da8..8f637e1d 100644 --- a/src/services/baseService.test.ts +++ b/src/services/baseService.test.ts @@ -69,7 +69,8 @@ describe("Base Service", () => { expect(props.subscriptionId).toEqual(sls.variables["subscriptionId"]); expect(props.serviceName).toEqual(slsConfig.service); expect(props.resourceGroup).toEqual(slsConfig.provider.resourceGroup); - expect(props.deploymentName).toEqual(slsConfig.provider.deploymentName); + const expectedDeploymentNameRegex = new RegExp(slsConfig.provider.deploymentName + "-t([0-9]+)") + expect(props.deploymentName).toMatch(expectedDeploymentNameRegex); }); it("Sets default region and stage values if not defined", () => { diff --git a/src/services/baseService.ts b/src/services/baseService.ts index c45a86a8..b99a5ce9 100644 --- a/src/services/baseService.ts +++ b/src/services/baseService.ts @@ -2,17 +2,20 @@ import axios from "axios"; import fs from "fs"; import request from "request"; import Serverless from "serverless"; +import { StorageAccountResource } from "../armTemplates/resources/storageAccount"; +import { configConstants } from "../config"; +import { DeploymentConfig, ServerlessAzureConfig } from "../models/serverless"; import { Guard } from "../shared/guard"; -import { ServerlessAzureConfig } from "../models/serverless"; +import { TokenCredentialsBase } from "@azure/ms-rest-nodeauth"; export abstract class BaseService { protected baseUrl: string; protected serviceName: string; - protected credentials: any; + protected credentials: TokenCredentialsBase protected subscriptionId: string; protected resourceGroup: string; protected deploymentName: string; - protected deploymentContainerName: string; + protected deploymentConfig: DeploymentConfig protected storageAccountName: string; protected config: ServerlessAzureConfig; @@ -26,12 +29,14 @@ export abstract class BaseService { this.setDefaultValues(); this.baseUrl = "https://management.azure.com"; + this.serviceName = this.getServiceName(); this.config = serverless.service as any; - this.serviceName = serverless.service["service"]; this.credentials = serverless.variables["azureCredentials"]; this.subscriptionId = serverless.variables["subscriptionId"]; this.resourceGroup = this.getResourceGroupName(); - this.deploymentName = serverless.service.provider["deploymentName"] || `${this.resourceGroup}-deployment`; + this.deploymentConfig = this.getDeploymentConfig(); + this.deploymentName = this.getDeploymentName(); + this.storageAccountName = StorageAccountResource.getResourceName(serverless.service as any) if (!this.credentials && authenticate) { throw new Error(`Azure Credentials has not been set in ${this.constructor.name}`); @@ -52,6 +57,31 @@ export abstract class BaseService { || `${this.config.provider.prefix}-${this.getRegion()}-${this.getStage()}-${this.serviceName}-rg`; } + public getDeploymentConfig(): DeploymentConfig { + const providedConfig = this.serverless["deploy"] as DeploymentConfig; + const config = providedConfig || { + rollback: configConstants.rollbackEnabled, + container: configConstants.deploymentArtifactContainer, + } + return config; + } + + public getDeploymentName(): string { + const name = this.serverless.service.provider["deploymentName"] || `${this.resourceGroup}-deployment` + return this.rollbackConfiguredName(name); + } + + public getServiceName(): string { + return this.serverless.service["service"]; + } + + /** + * Get the access token from credentials token cache + */ + protected getAccessToken(): string{ + return (this.credentials.tokenCache as any)._entries[0].accessToken; + } + /** * Sends an API request using axios HTTP library * @param method The HTTP method @@ -60,7 +90,7 @@ export abstract class BaseService { */ protected async sendApiRequest(method: string, relativeUrl: string, options: any = {}) { const defaultHeaders = { - Authorization: `Bearer ${this.credentials.tokenCache._entries[0].accessToken}`, + Authorization: `Bearer ${this.getAccessToken()}`, }; const allHeaders = { @@ -125,4 +155,25 @@ export abstract class BaseService { this.serverless.service.provider["prefix"] = "sls"; } } + + /** + * Add `-t{timestamp}` if rollback is enabled + * @param name Original name + */ + private rollbackConfiguredName(name: string) { + return (this.deploymentConfig.rollback) ? `${name}-t${this.getTimestamp()}` : name; + } + + /** + * Get timestamp from `packageTimestamp` serverless variable + * If not set, create timestamp, set variable and return timestamp + */ + private getTimestamp(): number { + let timestamp = +this.serverless.variables["packageTimestamp"]; + if (!timestamp) { + timestamp = Date.now(); + this.serverless.variables["packageTimestamp"] = timestamp; + } + return timestamp; + } } diff --git a/src/services/functionAppService.test.ts b/src/services/functionAppService.test.ts index e6def2c9..981c5b4b 100644 --- a/src/services/functionAppService.test.ts +++ b/src/services/functionAppService.test.ts @@ -10,7 +10,12 @@ import { FunctionAppResource } from "../armTemplates/resources/functionApp"; jest.mock("@azure/arm-appservice") import { WebSiteManagementClient } from "@azure/arm-appservice"; import { ArmDeployment, ArmTemplateType } from "../models/armTemplates"; -jest.mock("@azure/arm-resources") +jest.mock("@azure/arm-resources"); + +jest.mock("./azureBlobStorageService"); +import { AzureBlobStorageService } from "./azureBlobStorageService" +import configConstants from "../config"; + describe("Function App Service", () => { const app = MockFactory.createTestSite(); @@ -99,7 +104,7 @@ describe("Function App Service", () => { expect(result).toBeNull(); }); - it("gets master key", async () => { + fit("gets master key", async () => { const service = createService(); const result = await service.getMasterKey(); expect(result).toEqual(masterKey); @@ -190,7 +195,7 @@ describe("Function App Service", () => { }); }); - it("uploads functions", async () => { + it("uploads functions to function app and blob storage", async () => { const scmDomain = app.enabledHostNames.find((hostname) => hostname.endsWith("scm.azurewebsites.net")); const expectedUploadUrl = `https://${scmDomain}/api/zipdeploy/`; @@ -206,7 +211,15 @@ describe("Function App Service", () => { Accept: "*/*", ContentType: "application/octet-stream", } - }, slsService["artifact"]) + }, slsService["artifact"]); + const expectedArtifactName = service.getDeploymentName().replace("rg-deployment", "artifact"); + expect((AzureBlobStorageService.prototype as any).uploadFile).toBeCalledWith( + slsService["artifact"], + configConstants.deploymentArtifactContainer, + `${expectedArtifactName}.zip`, + ) + const uploadCall = ((AzureBlobStorageService.prototype as any).uploadFile).mock.calls[0]; + expect(uploadCall[2]).toMatch(/.*-t([0-9]+)/) }); it("uploads functions with custom SCM domain (aka App service environments)", async () => { diff --git a/src/services/functionAppService.ts b/src/services/functionAppService.ts index e83fd6dc..83c46643 100644 --- a/src/services/functionAppService.ts +++ b/src/services/functionAppService.ts @@ -1,21 +1,26 @@ +import { WebSiteManagementClient } from "@azure/arm-appservice"; +import { FunctionEnvelope, Site } from "@azure/arm-appservice/esm/models"; import fs from "fs"; import path from "path"; -import { WebSiteManagementClient } from "@azure/arm-appservice"; import Serverless from "serverless"; -import { BaseService } from "./baseService"; +import { FunctionAppResource } from "../armTemplates/resources/functionApp"; +import { ArmDeployment } from "../models/armTemplates"; import { FunctionAppHttpTriggerConfig } from "../models/functionApp"; -import { Site, FunctionEnvelope } from "@azure/arm-appservice/esm/models"; import { Guard } from "../shared/guard"; import { ArmService } from "./armService"; -import { ArmDeployment } from "../models/armTemplates"; -import { FunctionAppResource } from "../armTemplates/resources/functionApp"; +import { AzureBlobStorageService } from "./azureBlobStorageService"; +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 { @@ -46,7 +51,7 @@ export class FunctionAppService extends BaseService { Guard.null(functionApp); Guard.empty(functionName); - this.serverless.cli.log(`-> Deleting function: ${functionName}`); + this.log(`-> Deleting function: ${functionName}`); const deleteFunctionUrl = `${this.baseUrl}${functionApp.id}/functions/${functionName}?api-version=2016-08-01`; return await this.sendApiRequest("DELETE", deleteFunctionUrl); @@ -55,7 +60,7 @@ export class FunctionAppService extends BaseService { public async syncTriggers(functionApp: Site) { Guard.null(functionApp); - this.serverless.cli.log("Syncing function triggers"); + this.log("Syncing function triggers"); const syncTriggersUrl = `${this.baseUrl}${functionApp.id}/syncfunctiontriggers?api-version=2016-08-01`; return await this.sendApiRequest("POST", syncTriggersUrl); @@ -64,7 +69,7 @@ export class FunctionAppService extends BaseService { public async cleanUp(functionApp: Site) { Guard.null(functionApp); - this.serverless.cli.log("Cleaning up existing functions"); + this.log("Cleaning up existing functions"); const deleteTasks = []; const serviceFunctions = this.serverless.service.getAllFunctions(); @@ -109,8 +114,10 @@ export class FunctionAppService extends BaseService { public async uploadFunctions(functionApp: Site): Promise { Guard.null(functionApp, "functionApp"); - this.log("Deploying serverless functions..."); - await this.zipDeploy(functionApp); + this.log("Deploying serverless functions..."); + + await this.uploadZippedArfifactToFunctionApp(functionApp); + await this.uploadZippedArtifactToBlobStorage(); } /** @@ -131,23 +138,16 @@ export class FunctionAppService extends BaseService { return await this.get(); } - private async zipDeploy(functionApp) { - const functionAppName = functionApp.name; + private async uploadZippedArfifactToFunctionApp(functionApp) { const scmDomain = this.getScmDomain(functionApp); - this.serverless.cli.log(`Deploying zip file to function app: ${functionAppName}`); - - // Upload function artifact if it exists, otherwise the full service is handled in 'uploadFunctions' method - let functionZipFile = this.serverless.service["artifact"]; - if (!functionZipFile) { - functionZipFile = path.join(this.serverless.config.servicePath, ".serverless", `${this.serverless.service.getServiceName()}.zip`); - } + this.log(`Deploying zip file to function app: ${functionApp.name}`); - if (!(functionZipFile && fs.existsSync(functionZipFile))) { + if (!(this.functionZipFile && fs.existsSync(this.functionZipFile))) { throw new Error("No zip file found for function app"); } - this.serverless.cli.log(`-> Deploying service package @ ${functionZipFile}`); + this.log(`-> Deploying service package @ ${this.functionZipFile}`); // https://github.com/projectkudu/kudu/wiki/Deploying-from-a-zip-file-or-url const requestOptions = { @@ -155,18 +155,19 @@ export class FunctionAppService extends BaseService { uri: `https://${scmDomain}/api/zipdeploy/`, json: true, headers: { - Authorization: `Bearer ${this.credentials.tokenCache._entries[0].accessToken}`, + Authorization: `Bearer ${this.getAccessToken()}`, Accept: "*/*", ContentType: "application/octet-stream", } }; - await this.sendFile(requestOptions, functionZipFile); - this.serverless.cli.log("-> Function package uploaded successfully"); + await this.sendFile(requestOptions, this.functionZipFile); + + this.log("-> Function package uploaded successfully"); const serverlessFunctions = this.serverless.service.getAllFunctions(); const deployedFunctions = await this.listFunctions(functionApp); - this.serverless.cli.log("Deployed serverless functions:") + this.log("Deployed serverless functions:") deployedFunctions.forEach((functionConfig) => { // List functions that are part of the serverless yaml config if (serverlessFunctions.includes(functionConfig.name)) { @@ -174,13 +175,45 @@ export class FunctionAppService extends BaseService { if (httpConfig) { const method = httpConfig.methods[0].toUpperCase(); - this.serverless.cli.log(`-> ${functionConfig.name}: ${method} ${httpConfig.url}`); + this.log(`-> ${functionConfig.name}: ${method} ${httpConfig.url}`); } } }); } - private getFunctionHttpTriggerConfig(functionApp: Site, functionConfig: FunctionEnvelope): FunctionAppHttpTriggerConfig { + /** + * Uploads artifact file to blob storage container + */ + private async uploadZippedArtifactToBlobStorage() { + await this.blobService.initialize(); + await this.blobService.createContainerIfNotExists(this.deploymentConfig.container); + await this.blobService.uploadFile( + this.functionZipFile, + this.deploymentConfig.container, + this.getArtifactName(this.deploymentName), + ); + } + + /** + * Gets local path of packaged function app + */ + private getFunctionZipFile(): string { + let functionZipFile = this.serverless.service["artifact"]; + if (!functionZipFile) { + functionZipFile = path.join(this.serverless.config.servicePath, ".serverless", `${this.serverless.service.getServiceName()}.zip`); + } + return functionZipFile; + } + + /** + * Get rollback-configured artifact name. Contains `-t{timestamp}` + * if rollback is configured + */ + public getArtifactName(deploymentName: string): string { + return `${deploymentName.replace("rg-deployment", "artifact")}.zip`; + } + + public getFunctionHttpTriggerConfig(functionApp: Site, functionConfig: FunctionEnvelope): FunctionAppHttpTriggerConfig { const httpTrigger = functionConfig.config.bindings.find((binding) => { return binding.type === "httpTrigger"; }); @@ -201,29 +234,6 @@ export class FunctionAppService extends BaseService { }; } - private async runKuduCommand(functionApp: Site, command: string) { - this.serverless.cli.log(`-> Running Kudu command ${command}...`); - - const scmDomain = this.getScmDomain(functionApp); - const requestUrl = `https://${scmDomain}/api/command`; - - // TODO: There is a case where the body will contain an error, but it's - // not actually an error. These are warnings from npm install. - const response = await this.sendApiRequest("POST", requestUrl, { - data: { - command: command, - dir: "site\\wwwroot" - } - }); - - if (response.status !== 200) { - if (response.data && response.data.Error) { - throw new Error(response.data.Error); - } - throw new Error(`Error executing ${command} command, try again later.`); - } - } - /** * Gets a short lived admin token used to retrieve function keys */ diff --git a/src/services/invokeService.test.ts b/src/services/invokeService.test.ts new file mode 100644 index 00000000..c473af55 --- /dev/null +++ b/src/services/invokeService.test.ts @@ -0,0 +1,73 @@ +import axios from "axios"; +import MockAdapter from "axios-mock-adapter"; +import { MockFactory } from "../test/mockFactory"; +import { InvokeService } from "./invokeService"; +jest.mock("@azure/arm-appservice") +jest.mock("@azure/arm-resources") +jest.mock("./functionAppService") +import { FunctionAppService } from "./functionAppService"; + +describe("Invoke Service ", () => { + const app = MockFactory.createTestSite(); + const expectedSite = MockFactory.createTestSite(); + const testData = "test-data"; + const testResult = "test-data"; + const authKey = "authKey"; + const baseUrl = "https://management.azure.com" + const masterKeyUrl = `https://${app.defaultHostName}/admin/host/systemkeys/_master`; + const authKeyUrl = `${baseUrl}${app.id}/functions/admin/token?api-version=2016-08-01`; + let urlPOST = `http://${app.defaultHostName}/api/hello`; + let urlGET = `http://${app.defaultHostName}/api/hello?name=${testData}`; + let masterKey: string; + + beforeAll(() => { + const axiosMock = new MockAdapter(axios); + // Master Key + axiosMock.onGet(masterKeyUrl).reply(200, { value: masterKey }); + // Auth Key + axiosMock.onGet(authKeyUrl).reply(200, authKey); + //Mock url for GET + axiosMock.onGet(urlGET).reply(200, testResult); + //Mock url for POST + axiosMock.onPost(urlPOST).reply(200, testResult); + }); + + beforeEach(() => { + FunctionAppService.prototype.getMasterKey = jest.fn(); + FunctionAppService.prototype.get = jest.fn(() => Promise.resolve(expectedSite)); + }); + + it("Invokes a function with GET request", async () => { + const sls = MockFactory.createTestServerless(); + const options = MockFactory.createTestServerlessOptions(); + const expectedResult = {url: `${app.defaultHostName}/api/hello`}; + const httpConfig = jest.fn(() => expectedResult); + + FunctionAppService.prototype.getFunctionHttpTriggerConfig = httpConfig as any; + + options["function"] = "hello"; + options["data"] = `{"name": "${testData}"}`; + options["method"] = "GET"; + + const service = new InvokeService(sls, options); + const response = await service.invoke(options["method"], options["function"], options["data"]); + expect(JSON.stringify(response.data)).toEqual(JSON.stringify(testResult)); + }); + + it("Invokes a function with POST request", async () => { + const sls = MockFactory.createTestServerless(); + const options = MockFactory.createTestServerlessOptions(); + const expectedResult = {url: `${app.defaultHostName}/api/hello`}; + const httpConfig = jest.fn(() => expectedResult); + FunctionAppService.prototype.getFunctionHttpTriggerConfig = httpConfig as any; + + options["function"] = "hello"; + options["data"] = `{"name": "${testData}"}`; + options["method"] = "POST"; + + const service = new InvokeService(sls, options); + const response = await service.invoke(options["method"], options["function"], options["data"]); + expect(JSON.stringify(response.data)).toEqual(JSON.stringify(testResult)); + }); + +}); \ No newline at end of file diff --git a/src/services/invokeService.ts b/src/services/invokeService.ts new file mode 100644 index 00000000..8cb534b7 --- /dev/null +++ b/src/services/invokeService.ts @@ -0,0 +1,86 @@ +import { BaseService } from "./baseService" +import Serverless from "serverless"; +import axios from "axios"; +import { FunctionAppService } from "./functionAppService"; + +export class InvokeService extends BaseService { + public functionAppService: FunctionAppService; + + public constructor(serverless: Serverless, options: Serverless.Options) { + super(serverless, options); + this.functionAppService = new FunctionAppService(serverless, options); + } + + /** + * Invoke an Azure Function + * @param method HTTP method + * @param functionName Name of function to invoke + * @param data Data to use as body or query params + */ + public async invoke(method: string, functionName: string, data?: any){ + + /* accesses the admin key */ + if (!(functionName in this.slsFunctions())) { + this.serverless.cli.log(`Function ${functionName} does not exist`); + } + + const functionObject = this.slsFunctions()[functionName]; + const eventType = Object.keys(functionObject["events"][0])[0]; + + if (eventType !== "http") { + this.log("Needs to be an http function"); + return; + } + + const functionApp = await this.functionAppService.get(); + const functionConfig = await this.functionAppService.getFunction(functionApp, functionName); + const httpConfig = this.functionAppService.getFunctionHttpTriggerConfig(functionApp, functionConfig); + let url = "http://" + httpConfig.url; + + if (method === "GET" && data) { + const queryString = this.getQueryString(data); + url += `?${queryString}` + } + + this.log(url); + const options = await this.getOptions(method, data); + this.log(`Invoking function ${functionName} with ${method} request`); + return await axios(url, options); + } + + private getQueryString(eventData: any) { + if (typeof eventData === "string") { + try { + eventData = JSON.parse(eventData); + } + catch (error) { + return Promise.reject("The specified input data isn't a valid JSON string. " + + "Please correct it and try invoking the function again."); + } + } + return Object.keys(eventData) + .map((key) => `${key}=${eventData[key]}`) + .join("&"); + } + + /** + * Get options object + * @param method The method used (POST or GET) + * @param data Data to use as body or query params + */ + private async getOptions(method: string, data?: any) { + + const functionsAdminKey = await this.functionAppService.getMasterKey(); + const functionApp = await this.functionAppService.get(); + const options: any = { + host: functionApp.defaultHostName, + headers: { + "x-functions-key": functionsAdminKey + }, + method, + data, + }; + + return options; + } +} \ No newline at end of file diff --git a/src/services/loginService.test.ts b/src/services/loginService.test.ts index 2e8d23c9..a43e5114 100644 --- a/src/services/loginService.test.ts +++ b/src/services/loginService.test.ts @@ -30,7 +30,8 @@ describe("Login Service", () => { expect(loginWithServicePrincipalSecretWithAuthResponse).toBeCalledWith( "azureServicePrincipalClientId", "azureServicePrincipalPassword", - "azureServicePrincipalTenantId" + "azureServicePrincipalTenantId", + undefined // would be options ); }); }); \ No newline at end of file diff --git a/src/services/loginService.ts b/src/services/loginService.ts index b6afa60b..736671a4 100644 --- a/src/services/loginService.ts +++ b/src/services/loginService.ts @@ -3,28 +3,36 @@ import { interactiveLoginWithAuthResponse, loginWithServicePrincipalSecretWithAuthResponse, AuthResponse, + AzureTokenCredentialsOptions, + InteractiveLoginOptions, } from "@azure/ms-rest-nodeauth"; export class AzureLoginService { - public static async login(): Promise { + + /** + * Logs in via service principal login if environment variables are + * set or via interactive login if environment variables are not set + * @param options Options for different authentication methods + */ + public static async login(options?: AzureTokenCredentialsOptions|InteractiveLoginOptions): Promise { const subscriptionId = process.env.azureSubId; const clientId = process.env.azureServicePrincipalClientId; const secret = process.env.azureServicePrincipalPassword; const tenantId = process.env.azureServicePrincipalTenantId; if (subscriptionId && clientId && secret && tenantId) { - return await AzureLoginService.servicePrincipalLogin(clientId, secret, tenantId); + return await AzureLoginService.servicePrincipalLogin(clientId, secret, tenantId, options); } else { - return await AzureLoginService.interactiveLogin(); + return await AzureLoginService.interactiveLogin(options); } } - public static async interactiveLogin(): Promise { + public static async interactiveLogin(options: InteractiveLoginOptions): Promise { await open("https://microsoft.com/devicelogin"); - return await interactiveLoginWithAuthResponse(); + return await interactiveLoginWithAuthResponse(options); } - public static async servicePrincipalLogin(clientId: string, secret: string, tenantId: string): Promise { - return loginWithServicePrincipalSecretWithAuthResponse(clientId, secret, tenantId); + public static async servicePrincipalLogin(clientId: string, secret: string, tenantId: string, options: AzureTokenCredentialsOptions): Promise { + return loginWithServicePrincipalSecretWithAuthResponse(clientId, secret, tenantId, options); } } diff --git a/src/services/packageService.ts b/src/services/packageService.ts index 1bcd9431..89127a42 100644 --- a/src/services/packageService.ts +++ b/src/services/packageService.ts @@ -1,7 +1,7 @@ -import Serverless from "serverless"; -import path from "path"; import fs from "fs"; -import { Utils, FunctionMetadata } from "../shared/utils"; +import path from "path"; +import Serverless from "serverless"; +import { FunctionMetadata, Utils } from "../shared/utils"; /** * Adds service packing support diff --git a/src/services/resourceService.test.ts b/src/services/resourceService.test.ts index 22ac5ad1..f3743067 100644 --- a/src/services/resourceService.test.ts +++ b/src/services/resourceService.test.ts @@ -57,8 +57,10 @@ describe("Resource Service", () => { const options = MockFactory.createTestServerlessOptions(); const service = new ResourceService(sls, options); service.deleteDeployment(); - expect(ResourceManagementClient.prototype.deployments.deleteMethod) - .toBeCalledWith(resourceGroup, deploymentName); + const call = (ResourceManagementClient.prototype.deployments.deleteMethod as any).mock.calls[0]; + expect(call[0]).toEqual(resourceGroup); + const expectedDeploymentNameRegex = new RegExp(deploymentName + "-t([0-9]+)") + expect(call[1]).toMatch(expectedDeploymentNameRegex) }); it("deletes a resource group", () => { diff --git a/src/shared/utils.test.ts b/src/shared/utils.test.ts index 3f37d702..8f319178 100644 --- a/src/shared/utils.test.ts +++ b/src/shared/utils.test.ts @@ -64,4 +64,18 @@ describe("utils", () => { expect(metadata).toEqual(expectedMetadata); }); -}); \ No newline at end of file + + it("should create string from substrings", () => { + expect( + Utils.appendSubstrings( + 2, + "abcde", + "fghij", + "klmno", + "pqrst", + "uvwxyz", + "ab", + ) + ).toEqual("abfgklpquvab"); + }); +}); diff --git a/src/shared/utils.ts b/src/shared/utils.ts index f8102a93..8777c344 100644 --- a/src/shared/utils.ts +++ b/src/shared/utils.ts @@ -1,5 +1,5 @@ -import Serverless from "serverless"; import { relative } from "path"; +import Serverless from "serverless"; import { BindingUtils } from "./bindings"; import { constants } from "./constants"; @@ -123,4 +123,17 @@ export class Utils { const vals = params.values(); return new Function(...names, `return \`${template}\`;`)(...vals); } + + /** + * Take the first `substringSize` characters from each string and return as one string + * @param substringSize Size of substring to take from beginning of each string + * @param args Strings to take substrings from + */ + public static appendSubstrings(substringSize: number, ...args: string[]): string { + let result = ""; + for (const s of args) { + result += (s.substr(0, substringSize)); + } + return result; + } } diff --git a/src/test/mockFactory.ts b/src/test/mockFactory.ts index 7962cd74..6177bd7e 100644 --- a/src/test/mockFactory.ts +++ b/src/test/mockFactory.ts @@ -1,14 +1,21 @@ +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 { HttpHeaders, HttpOperationResponse, HttpResponse, WebResource } from "@azure/ms-rest-js"; -import { LinkedSubscription, TokenCredentialsBase } from "@azure/ms-rest-nodeauth"; -import { TokenResponse } from "@azure/ms-rest-nodeauth/dist/lib/credentials/tokenClientCredentials"; +import { AuthResponse, LinkedSubscription, TokenCredentialsBase } from "@azure/ms-rest-nodeauth"; +import { TokenClientCredentials, 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 { AxiosRequestConfig, 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 { ApiCorsPolicy, ApiManagementConfig } from "../models/apiManagement"; +import { ArmResourceTemplate } from "../models/armTemplates"; +import { AzureServiceProvider, ServicePrincipalEnvVariables } from "../models/azureProvider"; +import { Logger } from "../models/generic"; +import { ServerlessAzureConfig } from "../models/serverless"; function getAttribute(object: any, prop: string, defaultValue: any): any { if (object && object[prop]) { @@ -205,7 +212,7 @@ export class MockFactory { } public static createTestAzureBlobItem(id: number = 1, index: number = 1, ext: string = ".zip") { - return { + return { name: `blob-${id}-${index}${ext}` } } From 399384d8f8c813d686b5b5f42e31782bc9fc2e66 Mon Sep 17 00:00:00 2001 From: Neeraj Mandal Date: Mon, 8 Jul 2019 15:15:58 -0700 Subject: [PATCH 2/4] feat: Invoke Plugin --- src/services/functionAppService.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/functionAppService.test.ts b/src/services/functionAppService.test.ts index b63a395d..eebc61a2 100644 --- a/src/services/functionAppService.test.ts +++ b/src/services/functionAppService.test.ts @@ -104,7 +104,7 @@ describe("Function App Service", () => { expect(result).toBeNull(); }); - fit("gets master key", async () => { + it("gets master key", async () => { const service = createService(); const result = await service.getMasterKey(); expect(result).toEqual(masterKey); From 607dd4e74b9eb3dc1f32570f6b320cef1beae628 Mon Sep 17 00:00:00 2001 From: Neeraj Mandal Date: Mon, 8 Jul 2019 15:45:01 -0700 Subject: [PATCH 3/4] Resolved conflicts --- src/services/invokeService.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/services/invokeService.ts b/src/services/invokeService.ts index 8cb534b7..9d89b3a7 100644 --- a/src/services/invokeService.ts +++ b/src/services/invokeService.ts @@ -22,6 +22,7 @@ export class InvokeService extends BaseService { /* accesses the admin key */ if (!(functionName in this.slsFunctions())) { this.serverless.cli.log(`Function ${functionName} does not exist`); + return; } const functionObject = this.slsFunctions()[functionName]; From 003284230058c8d6f0dc25454f1dfb6007626d68 Mon Sep 17 00:00:00 2001 From: Neeraj Mandal Date: Tue, 9 Jul 2019 09:38:21 -0700 Subject: [PATCH 4/4] url encoder added & conflicts resoloved --- package-lock.json | 41 ++++++++++++++++++++++-------- src/services/invokeService.test.ts | 2 +- src/services/invokeService.ts | 8 +++--- 3 files changed, 35 insertions(+), 16 deletions(-) diff --git a/package-lock.json b/package-lock.json index c150f915..b52ea548 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4737,7 +4737,8 @@ }, "ansi-regex": { "version": "2.1.1", - "bundled": true + "bundled": true, + "optional": true }, "aproba": { "version": "1.2.0", @@ -4755,11 +4756,13 @@ }, "balanced-match": { "version": "1.0.0", - "bundled": true + "bundled": true, + "optional": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, + "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -4772,15 +4775,18 @@ }, "code-point-at": { "version": "1.1.0", - "bundled": true + "bundled": true, + "optional": true }, "concat-map": { "version": "0.0.1", - "bundled": true + "bundled": true, + "optional": true }, "console-control-strings": { "version": "1.1.0", - "bundled": true + "bundled": true, + "optional": true }, "core-util-is": { "version": "1.0.2", @@ -4883,7 +4889,8 @@ }, "inherits": { "version": "2.0.3", - "bundled": true + "bundled": true, + "optional": true }, "ini": { "version": "1.3.5", @@ -4893,6 +4900,7 @@ "is-fullwidth-code-point": { "version": "1.0.0", "bundled": true, + "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -4905,17 +4913,20 @@ "minimatch": { "version": "3.0.4", "bundled": true, + "optional": true, "requires": { "brace-expansion": "^1.1.7" } }, "minimist": { "version": "0.0.8", - "bundled": true + "bundled": true, + "optional": true }, "minipass": { "version": "2.3.5", "bundled": true, + "optional": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -4932,6 +4943,7 @@ "mkdirp": { "version": "0.5.1", "bundled": true, + "optional": true, "requires": { "minimist": "0.0.8" } @@ -5004,7 +5016,8 @@ }, "number-is-nan": { "version": "1.0.1", - "bundled": true + "bundled": true, + "optional": true }, "object-assign": { "version": "4.1.1", @@ -5014,6 +5027,7 @@ "once": { "version": "1.4.0", "bundled": true, + "optional": true, "requires": { "wrappy": "1" } @@ -5089,7 +5103,8 @@ }, "safe-buffer": { "version": "5.1.2", - "bundled": true + "bundled": true, + "optional": true }, "safer-buffer": { "version": "2.1.2", @@ -5119,6 +5134,7 @@ "string-width": { "version": "1.0.2", "bundled": true, + "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -5136,6 +5152,7 @@ "strip-ansi": { "version": "3.0.1", "bundled": true, + "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -5174,11 +5191,13 @@ }, "wrappy": { "version": "1.0.2", - "bundled": true + "bundled": true, + "optional": true }, "yallist": { "version": "3.0.3", - "bundled": true + "bundled": true, + "optional": true } } }, diff --git a/src/services/invokeService.test.ts b/src/services/invokeService.test.ts index c473af55..0dd751a5 100644 --- a/src/services/invokeService.test.ts +++ b/src/services/invokeService.test.ts @@ -17,7 +17,7 @@ describe("Invoke Service ", () => { const masterKeyUrl = `https://${app.defaultHostName}/admin/host/systemkeys/_master`; const authKeyUrl = `${baseUrl}${app.id}/functions/admin/token?api-version=2016-08-01`; let urlPOST = `http://${app.defaultHostName}/api/hello`; - let urlGET = `http://${app.defaultHostName}/api/hello?name=${testData}`; + let urlGET = `http://${app.defaultHostName}/api/hello?name%3D${testData}`; let masterKey: string; beforeAll(() => { diff --git a/src/services/invokeService.ts b/src/services/invokeService.ts index 9d89b3a7..0b14fbcc 100644 --- a/src/services/invokeService.ts +++ b/src/services/invokeService.ts @@ -19,13 +19,13 @@ export class InvokeService extends BaseService { */ public async invoke(method: string, functionName: string, data?: any){ + const functionObject = this.slsFunctions()[functionName]; /* accesses the admin key */ - if (!(functionName in this.slsFunctions())) { + if (!functionObject) { this.serverless.cli.log(`Function ${functionName} does not exist`); return; } - const functionObject = this.slsFunctions()[functionName]; const eventType = Object.keys(functionObject["events"][0])[0]; if (eventType !== "http") { @@ -59,9 +59,9 @@ export class InvokeService extends BaseService { "Please correct it and try invoking the function again."); } } - return Object.keys(eventData) + return encodeURIComponent(Object.keys(eventData) .map((key) => `${key}=${eventData[key]}`) - .join("&"); + .join("&")); } /**