diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md new file mode 100644 index 00000000..45fbd25f --- /dev/null +++ b/docs/DEPLOY.md @@ -0,0 +1,58 @@ +# Overview + +Deploy usage guide and design decision. + +## Multi-stage, multi-region deploy + +By default, all functions are deploy to a stage (`dev`) and a region. The +autogenerated resource group name will reflect this information. The name +follow the Microsoft recommended [naming convention](https://docs.microsoft.com/en-us/azure/architecture/best-practices/naming-conventions#general). + +### Using exitsing resource group + +`sls deploy -s dev -r westus2 -g myResourceGroup` + +- Use resource group `myResourceGroup` +- Create/Update app service plan, app service +- Zipdeploy code + +### No resource group specified + +`sls deploy -s dev -r westus2` + +- Create if not exist resource group `-dev-westus2-rg` +- Create/Update app service plan, app service +- Zipdeploy code + +### Design decision on resource group support + +#### Specifying resourceGroup in serverless.yml + +- it is not clear if a resourceGroup has already been associated with a given stage and region. + +For example, we have the following default config + +```yaml +provider: + stage: dev + region: westus + resourceGroup: myResourceGroup +``` + +then user try to deploy +`sls deploy -s prod -r westus` + +1. Do we use the environment variables / creds associated with `dev` or `prod`? +1. Someone new to the codecase see both the `serverless.yaml` and the command in a CD script, how can they tell which resourceGroup goes where. + +#### Specifying resourceGroup in command line + +`sls deploy -s prod -r westus -g prodResourceGroup` + +1. With this format, it's more clear which stage+region combo is associated with a resourceGroup. +1. User can still make mistake, however, and use the `wrong` resource group for a specific stage. + +#### Don't support user defined resource group + +1. always using the right resource group +1. restrictive for user who have already defined their resources diff --git a/package-lock.json b/package-lock.json index b5ab78e3..11815598 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1753,6 +1753,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", @@ -1925,6 +1926,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", @@ -2394,7 +2396,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", @@ -2499,7 +2502,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", @@ -2528,6 +2532,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", @@ -2564,6 +2569,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" @@ -2660,7 +2666,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", @@ -2842,6 +2849,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" @@ -3219,6 +3227,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", @@ -3231,6 +3240,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", @@ -3689,6 +3699,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", @@ -3750,6 +3761,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" } @@ -4129,6 +4141,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" @@ -4144,6 +4157,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" @@ -4673,7 +4687,8 @@ }, "ansi-regex": { "version": "2.1.1", - "bundled": true + "bundled": true, + "optional": true }, "aproba": { "version": "1.2.0", @@ -5038,7 +5053,8 @@ }, "safe-buffer": { "version": "5.1.2", - "bundled": true + "bundled": true, + "optional": true }, "safer-buffer": { "version": "2.1.2", @@ -5086,6 +5102,7 @@ "strip-ansi": { "version": "3.0.1", "bundled": true, + "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -5124,11 +5141,13 @@ }, "wrappy": { "version": "1.0.2", - "bundled": true + "bundled": true, + "optional": true }, "yallist": { "version": "3.0.3", - "bundled": true + "bundled": true, + "optional": true } } }, @@ -5436,6 +5455,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" @@ -5445,6 +5465,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" @@ -5454,6 +5475,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", @@ -5772,7 +5794,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", @@ -7412,7 +7435,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", @@ -7503,6 +7527,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", @@ -7528,6 +7553,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" @@ -7618,12 +7644,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", @@ -8221,6 +8249,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", @@ -8329,6 +8358,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", @@ -8531,7 +8561,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", @@ -8598,6 +8629,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" } @@ -9010,6 +9042,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" @@ -9399,6 +9432,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" @@ -9594,7 +9628,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", @@ -10151,7 +10186,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", @@ -11001,6 +11037,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" @@ -11009,7 +11046,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/src/services/apimService.test.ts b/src/services/apimService.test.ts index 896c32a6..1b14fe4b 100644 --- a/src/services/apimService.test.ts +++ b/src/services/apimService.test.ts @@ -43,7 +43,7 @@ describe("APIM Service", () => { provider: { name: "azure", resourceGroup: "test-sls-rg", - location: "West US", + region: "West US", apim: apimConfig, }, }; @@ -91,7 +91,7 @@ describe("APIM Service", () => { const expectedResponse = interpolateJson(apimGetService200, { resourceGroup: { name: serverless.service.provider["resourceGroup"], - location: serverless.service.provider["location"], + location: serverless.service.provider.region, }, resource: { name: apimConfig.name, @@ -106,7 +106,7 @@ describe("APIM Service", () => { expect(resource).not.toBeNull(); expect(resource).toMatchObject({ name: apimConfig.name, - location: serverless.service.provider["location"], + location: serverless.service.provider.region, }); }); }); @@ -133,7 +133,7 @@ describe("APIM Service", () => { const expectedResponse = interpolateJson(apimGetApi200, { resourceGroup: { name: serverless.service.provider["resourceGroup"], - location: serverless.service.provider["location"], + location: serverless.service.provider.region, }, service: { name: apimConfig.name, diff --git a/src/services/baseService.test.ts b/src/services/baseService.test.ts index a7e0225a..ac3f65a2 100644 --- a/src/services/baseService.test.ts +++ b/src/services/baseService.test.ts @@ -8,7 +8,7 @@ import request from "request"; import fs from "fs"; import { BaseService } from "./baseService"; -class TestService extends BaseService { +class MockService extends BaseService { public constructor(serverless: Serverless, options?: Serverless.Options) { super(serverless, options); } @@ -21,6 +21,18 @@ class TestService extends BaseService { return this.sendFile(requestOptions, filePath); } + public getSlsResourceGroupName() { + return this.getResourceGroupName(); + } + + public getSlsRegion() { + return this.getRegion(); + } + + public getSlsStage() { + return this.getStage(); + } + public getProperties() { return { baseUrl: this.baseUrl, @@ -34,7 +46,7 @@ class TestService extends BaseService { } describe("Base Service", () => { - let service: TestService; + let service: MockService; let sls: Serverless; const slsConfig = { @@ -49,12 +61,17 @@ describe("Base Service", () => { mockFs.restore(); }); - beforeEach(() => { + function createTestService(options?: Serverless.Options) { sls = MockFactory.createTestServerless(); sls.variables["azureCredentials"] = MockFactory.createTestAzureCredentials(); sls.variables["subscriptionId"] = "ABC123"; Object.assign(sls.service, slsConfig); - service = new TestService(sls); + + return new MockService(sls, options); + } + + beforeEach(() => { + service = createTestService(); }); it("Initializes common service properties", () => { @@ -67,9 +84,45 @@ describe("Base Service", () => { expect(props.deploymentName).toEqual(slsConfig.provider.deploymentName); }); + it("Sets default region and stage values if not defined", () => { + const testService = new MockService(sls); + + expect(testService).not.toBeNull(); + expect(sls.service.provider.region).toEqual("westus"); + expect(sls.service.provider.stage).toEqual("dev"); + }); + + it("returns region and stage based on CLI options", () => { + const cliOptions = { + stage: "prod", + region: "eastus2" + }; + const testService = new MockService(sls, cliOptions); + + expect(testService.getSlsRegion()).toEqual(cliOptions.region); + expect(testService.getSlsStage()).toEqual(cliOptions.stage); + }); + + it("Generates resource group name from sls yaml config", () => { + const testService = new MockService(sls); + const resourceGroupName = testService.getSlsResourceGroupName(); + + expect(resourceGroupName).toEqual(sls.service.provider["resourceGroup"]); + }); + + it("Generates resource group from convention when NOT defined in sls yaml", () => { + sls.service.provider["resourceGroup"] = null; + const testService = new MockService(sls); + const resourceGroupName = testService.getSlsResourceGroupName(); + const region = testService.getSlsRegion(); + const stage = testService.getSlsStage(); + + expect(resourceGroupName).toEqual(`${sls.service["service"]}-${region}-${stage}-rg`); + }); + it("Fails if credentials have not been set in serverless config", () => { sls.variables["azureCredentials"] = null; - expect(() => new TestService(sls)).toThrow() + expect(() => new MockService(sls)).toThrow() }); it("Makes HTTP request via axios", async () => { diff --git a/src/services/baseService.ts b/src/services/baseService.ts index d95bd169..3f3c73a4 100644 --- a/src/services/baseService.ts +++ b/src/services/baseService.ts @@ -12,21 +12,27 @@ export abstract class BaseService { protected resourceGroup: string; protected deploymentName: string; - protected constructor(protected serverless: Serverless, protected options?: Serverless.Options, authenticate = true) { + protected constructor( + protected serverless: Serverless, + protected options: Serverless.Options = { stage: null, region: null }, + authenticate: boolean = true, + ) { Guard.null(serverless); this.baseUrl = "https://management.azure.com"; this.serviceName = serverless.service["service"]; this.credentials = serverless.variables["azureCredentials"]; this.subscriptionId = serverless.variables["subscriptionId"]; - this.resourceGroup = serverless.service.provider["resourceGroup"] || `${this.serviceName}-rg`; + this.resourceGroup = this.getResourceGroupName(); this.deploymentName = serverless.service.provider["deploymentName"] || `${this.resourceGroup}-deployment`; + this.setDefaultValues(); + if (!this.credentials && authenticate) { throw new Error(`Azure Credentials has not been set in ${this.constructor.name}`); } } - + /** * Sends an API request using axios HTTP library * @param method The HTTP method @@ -81,4 +87,33 @@ export abstract class BaseService { protected slsConfigFile(): string { return ("config" in this.options) ? this.options["config"] : "serverless.yml"; } + + private setDefaultValues(): void { + // TODO: Right now the serverless core will always default to AWS default region if the + // region has not been set in the serverless.yml or CLI options + const awsDefault = "us-east-1" + const providerRegion = this.serverless.service.provider.region; + + if (!providerRegion || providerRegion === awsDefault) { // no region specified in serverless.yml + this.serverless.service.provider.region = "westus"; + } + + if (!this.serverless.service.provider.stage) { + this.serverless.service.provider.stage = "dev"; + } + } + + protected getRegion(): string { + return this.options.region || this.serverless.service.provider.region; + } + + protected getStage(): string { + return this.options.stage || this.serverless.service.provider.stage; + } + + protected getResourceGroupName(): string { + return this.options["resourceGroup"] + || this.serverless.service.provider["resourceGroup"] + || `${this.serviceName}-${this.getRegion()}-${this.getStage()}-rg`; + } } diff --git a/src/services/functionAppService.test.ts b/src/services/functionAppService.test.ts index d87f2c9a..fce61cb8 100644 --- a/src/services/functionAppService.test.ts +++ b/src/services/functionAppService.test.ts @@ -2,7 +2,6 @@ import axios from "axios"; import MockAdapter from "axios-mock-adapter"; import mockFs from "mock-fs"; import Serverless from "serverless"; -import { constants } from "../config"; import { MockFactory } from "../test/mockFactory"; import { FunctionAppService } from "./functionAppService"; @@ -11,7 +10,7 @@ import { WebSiteManagementClient } from "@azure/arm-appservice"; jest.mock("@azure/arm-resources") describe("Function App Service", () => { - + const app = MockFactory.createTestSite(); const slsService = MockFactory.createTestService(); const variables = MockFactory.createTestVariables(); @@ -29,17 +28,18 @@ describe("Function App Service", () => { const authKeyUrl = `${baseUrl}${app.id}/functions/admin/token?api-version=2016-08-01`; const syncTriggersUrl = `${baseUrl}${app.id}/syncfunctiontriggers?api-version=2016-08-01`; const listFunctionsUrl = `${baseUrl}${app.id}/functions?api-version=2016-08-01`; - const uploadUrl = `https://${app.enabledHostNames[0]}${constants.scmZipDeployApiPath}/` + const scmDomain = app.enabledHostNames.find((hostname) => hostname.endsWith("scm.azurewebsites.net")); + const uploadUrl = `https://${scmDomain}/api/zipdeploy/`; - beforeAll(() => { + beforeAll(() => { // TODO: How to spy on defaul exported function? const axiosMock = new MockAdapter(axios); - + // Master Key axiosMock.onGet(masterKeyUrl).reply(200, { value: masterKey }); // Auth Key - axiosMock.onGet(authKeyUrl).reply(200, authKey); + axiosMock.onGet(authKeyUrl).reply(200, authKey); // Sync Triggers axiosMock.onPost(syncTriggersUrl).reply(200, syncTriggersMessage); // List Functions @@ -52,7 +52,7 @@ describe("Function App Service", () => { mockFs({ "app.zip": "contents", - }, {createCwd: true, createTmp: true}); + }, { createCwd: true, createTmp: true }); }); beforeEach(() => { @@ -80,8 +80,8 @@ describe("Function App Service", () => { }), options || MockFactory.createTestServerlessOptions() ) - } - + } + it("get returns function app", async () => { const service = createService(); const result = await service.get(); @@ -93,7 +93,7 @@ describe("Function App Service", () => { it("get returns null if error occurred", async () => { const service = createService(); WebSiteManagementClient.prototype.webApps = { - get: jest.fn(() => { return { error: { code: "ResourceNotFound"}}}), + get: jest.fn(() => { return { error: { code: "ResourceNotFound" } } }), deleteFunction: jest.fn(), } as any; const result = await service.get(); @@ -106,14 +106,14 @@ describe("Function App Service", () => { const service = createService(); const masterKey = await service.getMasterKey(); expect(masterKey).toEqual(masterKey); - + }); it("deletes function", async () => { const service = createService(); const response = await service.deleteFunction(app, Object.keys(functions)[0]); expect(response.data).toEqual(deleteFunctionMessage); - + }); it("syncs triggers", async () => { diff --git a/src/services/functionAppService.ts b/src/services/functionAppService.ts index 5ea7c7d3..4237f37a 100644 --- a/src/services/functionAppService.ts +++ b/src/services/functionAppService.ts @@ -9,6 +9,7 @@ import Serverless from "serverless"; import { BaseService } from "./baseService"; import { FunctionAppHttpTriggerConfig } from "../models/functionApp"; import { Site, FunctionEnvelope } from "@azure/arm-appservice/esm/models"; +import { Guard } from "../shared/guard"; export class FunctionAppService extends BaseService { private resourceClient: ResourceManagementClient; @@ -46,6 +47,9 @@ export class FunctionAppService extends BaseService { } public async deleteFunction(functionApp: Site, functionName: string) { + Guard.null(functionApp); + Guard.empty(functionName); + this.serverless.cli.log(`-> Deleting function: ${functionName}`); const deleteFunctionUrl = `${this.baseUrl}${functionApp.id}/functions/${functionName}?api-version=2016-08-01`; @@ -53,6 +57,8 @@ export class FunctionAppService extends BaseService { } public async syncTriggers(functionApp: Site) { + Guard.null(functionApp); + this.serverless.cli.log("Syncing function triggers"); const syncTriggersUrl = `${this.baseUrl}${functionApp.id}/syncfunctiontriggers?api-version=2016-08-01`; @@ -60,6 +66,8 @@ export class FunctionAppService extends BaseService { } public async cleanUp(functionApp: Site) { + Guard.null(functionApp); + this.serverless.cli.log("Cleaning up existing functions"); const deleteTasks = []; @@ -76,6 +84,8 @@ export class FunctionAppService extends BaseService { } public async listFunctions(functionApp: Site): Promise { + Guard.null(functionApp); + const getTokenUrl = `${this.baseUrl}${functionApp.id}/functions?api-version=2016-08-01`; const response = await this.sendApiRequest("GET", getTokenUrl); @@ -87,6 +97,9 @@ export class FunctionAppService extends BaseService { } public async getFunction(functionApp: Site, functionName: string): Promise { + Guard.null(functionApp); + Guard.empty(functionName); + const getFunctionUrl = `${this.baseUrl}${functionApp.id}/functions/${functionName}?api-version=2016-08-01`; const response = await this.sendApiRequest("GET", getFunctionUrl); @@ -98,6 +111,9 @@ export class FunctionAppService extends BaseService { } public async uploadFunctions(functionApp: Site): Promise { + Guard.null(functionApp); + + this.log("Deploying serverless functions..."); await this.zipDeploy(functionApp); } @@ -175,7 +191,7 @@ export class FunctionAppService extends BaseService { private async zipDeploy(functionApp) { const functionAppName = functionApp.name; - const scmDomain = functionApp.enabledHostNames[0]; + const scmDomain = this.getScmDomain(functionApp); this.serverless.cli.log(`Deploying zip file to function app: ${functionAppName}`); @@ -246,7 +262,7 @@ export class FunctionAppService extends BaseService { private async runKuduCommand(functionApp: Site, command: string) { this.serverless.cli.log(`-> Running Kudu command ${command}...`); - const scmDomain = functionApp.enabledHostNames[0]; + 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 @@ -275,4 +291,15 @@ export class FunctionAppService extends BaseService { return response.data.replace(/"/g, ""); } + + /** + * Retrieves the SCM domain from the list of enabled domains within the app + * Note: The SCM domain exposes additional API calls from the standard REST APIs. + * @param functionApp The function app / web site + */ + private getScmDomain(functionApp: Site) { + return functionApp.enabledHostNames.find((hostName: string) => { + return hostName.endsWith("scm.azurewebsites.net"); + }); + } } diff --git a/src/services/resourceService.test.ts b/src/services/resourceService.test.ts index 3ce6df36..f664b32b 100644 --- a/src/services/resourceService.test.ts +++ b/src/services/resourceService.test.ts @@ -36,7 +36,7 @@ describe("Resource Service", () => { const resourceGroup = "myResourceGroup" const location = "West Us"; sls.service.provider["resourceGroup"] = resourceGroup - sls.service.provider["location"] = location; + sls.service.provider.region = location; sls.variables["azureCredentials"] = "fake credentials" const options = MockFactory.createTestServerlessOptions(); const service = new ResourceService(sls, options); diff --git a/src/services/resourceService.ts b/src/services/resourceService.ts index 7bd46530..8d47c01c 100644 --- a/src/services/resourceService.ts +++ b/src/services/resourceService.ts @@ -14,11 +14,9 @@ export class ResourceService extends BaseService { public async deployResourceGroup() { this.log(`Creating resource group: ${this.resourceGroup}`); - const groupParameters = { - location: this.serverless.service.provider["location"] - }; - - return await this.resourceClient.resourceGroups.createOrUpdate(this.resourceGroup, groupParameters); + return await this.resourceClient.resourceGroups.createOrUpdate(this.resourceGroup, { + location: this.getRegion(), + }); } public async deleteDeployment() { diff --git a/src/shared/utils.ts b/src/shared/utils.ts index 9b8654a1..f8102a93 100644 --- a/src/shared/utils.ts +++ b/src/shared/utils.ts @@ -113,6 +113,10 @@ export class Utils { return metaData; } + public static prettyPrint(json) { + return JSON.stringify(json, null, 2); + } + public static interpolateFile(sls: Serverless, path: string, params: Map) { const template = sls.utils.readFileSync(path); const names = params.keys(); diff --git a/src/test/mockFactory.ts b/src/test/mockFactory.ts index 7efaaa32..ac72bc39 100644 --- a/src/test/mockFactory.ts +++ b/src/test/mockFactory.ts @@ -184,7 +184,7 @@ export class MockFactory { const data = { provider: { name: "azure", - location: "West US 2" + region: "West US 2" }, plugins: [ "serverless-azure-functions" @@ -278,9 +278,10 @@ export class MockFactory { id: "appId", name: name, location: "West US", - defaultHostName: "myHostName", + defaultHostName: "myHostName.azurewebsites.net", enabledHostNames: [ - "myHostName" + "myHostName.azurewebsites.net", + "myHostName.scm.azurewebsites.net", ] }; }