diff --git a/package-lock.json b/package-lock.json index d8bbd4d6..e4fb69ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index c95c3868..2d65b3f3 100644 --- a/package.json +++ b/package.json @@ -39,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/config.ts b/src/config.ts index 2f08214d..f24db2d1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -11,7 +11,7 @@ export const configConstants = { logStreamApiPath: "/api/logstream/application/functions/function/", masterKeyApiPath: "/api/functions/admin/masterkey", providerName: "azure", - rollbackEnabled: false, + rollbackEnabled: true, scmCommandApiPath: "/api/command", scmDomain: ".scm.azurewebsites.net", scmVfsPath: "/api/vfs/site/wwwroot/", diff --git a/src/plugins/deploy/azureDeployPlugin.ts b/src/plugins/deploy/azureDeployPlugin.ts index 00b19414..40362ed6 100644 --- a/src/plugins/deploy/azureDeployPlugin.ts +++ b/src/plugins/deploy/azureDeployPlugin.ts @@ -52,7 +52,6 @@ export class AzureDeployPlugin { await resourceService.deployResourceGroup(); const functionAppService = new FunctionAppService(this.serverless, this.options); - await functionAppService.initialize(); const functionApp = await functionAppService.deploy(); await functionAppService.uploadFunctions(functionApp); 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/azureBlobStorageService.test.ts b/src/services/azureBlobStorageService.test.ts index e8d10a2c..d4d1a10a 100644 --- a/src/services/azureBlobStorageService.test.ts +++ b/src/services/azureBlobStorageService.test.ts @@ -1,10 +1,14 @@ import mockFs from "mock-fs"; import { MockFactory } from "../test/mockFactory"; -import { AzureBlobStorageService } from "./azureBlobStorageService"; +import { AzureBlobStorageService, AzureStorageAuthType } from "./azureBlobStorageService"; jest.mock("@azure/storage-blob"); jest.genMockFromModule("@azure/storage-blob") -import { Aborter, BlockBlobURL, ContainerURL, ServiceURL, uploadFileToBlockBlob, TokenCredential } from "@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" @@ -18,16 +22,27 @@ 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(() => { - (TokenCredential as any).mockImplementation((token: string) => { - token - }); + (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({ @@ -51,8 +66,20 @@ 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 () => { @@ -61,7 +88,7 @@ describe("Azure Blob Storage Service", () => { }); 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( @@ -99,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 8726dc06..6764d2ba 100644 --- a/src/services/azureBlobStorageService.ts +++ b/src/services/azureBlobStorageService.ts @@ -1,9 +1,16 @@ -import { Aborter, BlockBlobURL, ContainerURL, Credential, ServiceURL, StorageURL, TokenCredential, 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 { 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 */ @@ -13,15 +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 storageCredential: Credential; + 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 = new TokenCredential(await this.getToken()); + this.storageCredential = (this.authType === AzureStorageAuthType.SharedKey) + ? + new SharedKeyCredential(this.storageAccountName, await this.getKey()) + : + new TokenCredential(await this.getToken()); } /** @@ -31,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"); }; /** @@ -44,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); } @@ -57,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); @@ -80,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 { @@ -100,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); + } } /** @@ -111,15 +144,57 @@ 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 { + this.checkInitialization(); + const pipeline = StorageURL.newPipeline(this.storageCredential); const accountUrl = this.accountUrl; const serviceUrl = new ServiceURL( @@ -135,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 @@ -148,14 +225,19 @@ 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/" @@ -163,4 +245,24 @@ export class AzureBlobStorageService extends BaseService { 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 642da6b0..b99a5ce9 100644 --- a/src/services/baseService.ts +++ b/src/services/baseService.ts @@ -16,7 +16,6 @@ export abstract class BaseService { protected resourceGroup: string; protected deploymentName: string; protected deploymentConfig: DeploymentConfig - protected deploymentContainerName: string; protected storageAccountName: string; protected config: ServerlessAzureConfig; @@ -60,12 +59,6 @@ export abstract class BaseService { public getDeploymentConfig(): DeploymentConfig { const providedConfig = this.serverless["deploy"] as DeploymentConfig; - const providedDepName = this.serverless.service.provider["deploymentName"]; - if (providedConfig && providedDepName && providedConfig.rollback) { - throw new Error("Cannot both specify a deployment name and enable rollback. " + - "In order for rollback to work, the name of deployment must follow the generated " + - "naming convention") - } const config = providedConfig || { rollback: configConstants.rollbackEnabled, container: configConstants.deploymentArtifactContainer, @@ -82,14 +75,6 @@ export abstract class BaseService { return this.serverless.service["service"]; } - /** - * Get rollback-configured artifact name. Contains `-t{timestamp}` - * if rollback is configured - */ - public getArtifactName(): string { - return this.rollbackConfiguredName(this.getServiceName()); - } - /** * Get the access token from credentials token cache */ diff --git a/src/services/functionAppService.test.ts b/src/services/functionAppService.test.ts index e6def2c9..f923c6d2 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(); @@ -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 eb1a7176..ab5e69b2 100644 --- a/src/services/functionAppService.ts +++ b/src/services/functionAppService.ts @@ -13,10 +13,14 @@ 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 { @@ -43,22 +47,11 @@ export class FunctionAppService extends BaseService { return response.data.value; } - /** - * Initialize deployment artifact container if rollback is specified - */ - public async initialize() { - if (this.deploymentConfig.rollback) { - const blobService = new AzureBlobStorageService(this.serverless, this.options); - await blobService.initialize(); - blobService.createContainer(this.deploymentConfig.container); - } - } - public async deleteFunction(functionApp: Site, functionName: string) { 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); @@ -67,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); @@ -76,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(); @@ -123,7 +116,8 @@ export class FunctionAppService extends BaseService { this.log("Deploying serverless functions..."); - await this.zipDeploy(functionApp); + await this.uploadZippedArfifactToFunctionApp(functionApp); + await this.uploadZippedArtifactToBlobStorage(); } /** @@ -144,22 +138,16 @@ export class FunctionAppService extends BaseService { return await this.get(); } - private async zipDeploy(functionApp) { + private async uploadZippedArfifactToFunctionApp(functionApp) { const scmDomain = this.getScmDomain(functionApp); - this.serverless.cli.log(`Deploying zip file to function app: ${functionApp.name}`); + this.log(`Deploying zip file to function app: ${functionApp.name}`); - // 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`); - } - - 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 = { @@ -173,12 +161,13 @@ export class FunctionAppService extends BaseService { } }; - 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)) { @@ -186,12 +175,44 @@ 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}`); } } }); } + /** + * 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`; + } + private getFunctionHttpTriggerConfig(functionApp: Site, functionConfig: FunctionEnvelope): FunctionAppHttpTriggerConfig { const httpTrigger = functionConfig.config.bindings.find((binding) => { return binding.type === "httpTrigger"; 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", () => {