Skip to content
This repository has been archived by the owner on Dec 9, 2024. It is now read-only.

Commit

Permalink
feat: Updates to deployment process to enable rollback (Part 2) (#185)
Browse files Browse the repository at this point in the history
## High Level Overview
- Uploads a timestamped archive of code to Azure Blob Storage
- Adds same timestamp to ARM deployments to enable linking
- **Does not set `WEBSITE_RUN_FROM_PACKAGE` to use package directly from blob storage**
  - Currently just used as an archive. Initially, thought to update the `WEBSITE_RUN_FROM_PACKAGE` setting with the URL for the blob (hence the implementation of the SAS URL generator), but discovered [issues with cold start when using that methodology to deploy to Windows](https://docs.microsoft.com/en-us/azure/azure-functions/run-functions-from-deployment-package#enabling-functions-to-run-from-a-package).
  - Future `rollback` implementation will download the appropriate archive and re-deploy it to the Function App.
  - This may (and probably will) change as the Azure Functions Premium plan is released on Linux or cold start issues are mitigated

## Details

- Adds dependency on the `@azure/arm-storage` package
- Sets `rollbackEnabled` as default
- Updates broken tests because of timestamped deployment name
- Add tests for blob storage operations
- Allows user to still specify deployment name with rollback

Resolves [AB#388], [AB#358], [AB#414] and [AB#415]
  • Loading branch information
tbarlow12 authored Jul 1, 2019
1 parent fd5e79a commit 659282e
Show file tree
Hide file tree
Showing 12 changed files with 243 additions and 78 deletions.
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/",
Expand Down
1 change: 0 additions & 1 deletion src/plugins/deploy/azureDeployPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
6 changes: 5 additions & 1 deletion src/services/armService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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);
});
});
});
43 changes: 35 additions & 8 deletions src/services/azureBlobStorageService.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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({
Expand All @@ -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 () => {
Expand All @@ -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(
Expand Down Expand Up @@ -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);
});
Expand Down
136 changes: 119 additions & 17 deletions src/services/azureBlobStorageService.ts
Original file line number Diff line number Diff line change
@@ -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
*/
Expand All @@ -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());
}

/**
Expand All @@ -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");
};

/**
Expand All @@ -44,8 +66,10 @@ export class AzureBlobStorageService extends BaseService {
* @param blobName Blob to delete
*/
public async deleteFile(containerName: string, blobName: string): Promise<void> {
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);
}
Expand All @@ -57,6 +81,8 @@ export class AzureBlobStorageService extends BaseService {
*/
public async listFiles(containerName: string, ext?: string): Promise<string[]> {
Guard.empty(containerName, "containerName");
this.checkInitialization();

const result: string[] = [];
let marker;
const containerURL = this.getContainerURL(containerName);
Expand All @@ -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 {
Expand All @@ -100,26 +128,73 @@ 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<void> {
Guard.empty(containerName);
const containerURL = this.getContainerURL(containerName);
await containerURL.create(Aborter.none);
public async createContainerIfNotExists(containerName: string): Promise<void> {
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);
}
}

/**
* Delete a container from Azure Blob Storage Account
* @param containerName Name of container to delete
*/
public async deleteContainer(containerName: string): Promise<void> {
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<string> {
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(
Expand All @@ -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
Expand All @@ -148,19 +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<string> {
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<string> {
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");
}
}
3 changes: 2 additions & 1 deletion src/services/baseService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading

0 comments on commit 659282e

Please sign in to comment.