Skip to content

Commit

Permalink
feat: Rollback Plugin (#191)
Browse files Browse the repository at this point in the history
## Updates
- Adds new `rollback` plugin
- Fixes `deploy list` to show actual timestamp from name, which is used for rollback
- Adds required functionality to `AzureBlobStorageService` and `ArmService`
- Adds previously missing tests of required functionality

## How to Use
1. Get a timestamp. Use
```
sls deploy list
```
to retrieve one.
2. Roll back function app to timestamp
```
sls rollback -t <timestamp>
```

## Options
In `deploy` section of `serverless.yml`, you can specify whether to run from a blob URL or deploy the package directly to the function app by setting:
```yaml
deploy:
  # Default is false
  runFromBlobUrl: true|false
```

Resolves [AB#317]
  • Loading branch information
tbarlow12 authored Jul 6, 2019
1 parent 74fdbe6 commit cb1f295
Show file tree
Hide file tree
Showing 23 changed files with 632 additions and 120 deletions.
96 changes: 22 additions & 74 deletions package-lock.json

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion src/armTemplates/resources/functionApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ export class FunctionAppResource implements ArmResourceTemplateGenerator {
"$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"functionAppRunFromPackage": {
"defaultValue": "1",
"type": "String"
},
"functionAppName": {
"defaultValue": "",
"type": "String"
Expand Down Expand Up @@ -86,7 +90,7 @@ export class FunctionAppResource implements ArmResourceTemplateGenerator {
},
{
"name": "WEBSITE_RUN_FROM_PACKAGE",
"value": "1"
"value": "[parameters('functionAppRunFromPackage')]"
},
{
"name": "APPINSIGHTS_INSTRUMENTATIONKEY",
Expand Down
7 changes: 5 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
export const configConstants = {
bearer: "Bearer ",
deploymentArtifactContainer: "deployment-artifacts",
deploymentConfig: {
container: "deployment-artifacts",
rollback: true,
runFromBlobUrl: false,
},
functionAppApiPath: "/api/",
functionAppDomain: ".azurewebsites.net",
functionsAdminApiPath: "/admin/functions/",
Expand All @@ -11,7 +15,6 @@ export const configConstants = {
logStreamApiPath: "/api/logstream/application/functions/function/",
masterKeyApiPath: "/api/functions/admin/masterkey",
providerName: "azure",
rollbackEnabled: true,
scmCommandApiPath: "/api/command",
scmDomain: ".scm.azurewebsites.net",
scmVfsPath: "/api/vfs/site/wwwroot/",
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { AzureApimServicePlugin } from "./plugins/apim/apimServicePlugin";
import { AzureApimFunctionPlugin } from "./plugins/apim/apimFunctionPlugin";
import { AzureFuncPlugin } from "./plugins/func/azureFunc";
import { AzureOfflinePlugin } from "./plugins/offline/azureOfflinePlugin"
import { AzureRollbackPlugin } from "./plugins/rollback/azureRollbackPlugin"


export default class AzureIndex {
Expand All @@ -34,6 +35,7 @@ export default class AzureIndex {
this.serverless.pluginManager.addPlugin(AzureApimFunctionPlugin);
this.serverless.pluginManager.addPlugin(AzureFuncPlugin);
this.serverless.pluginManager.addPlugin(AzureOfflinePlugin);
this.serverless.pluginManager.addPlugin(AzureRollbackPlugin);
}
}

Expand Down
1 change: 1 addition & 0 deletions src/models/serverless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export interface FunctionAppConfig extends ResourceConfig {
export interface DeploymentConfig {
rollback?: boolean;
container?: string;
runFromBlobUrl?: boolean;
}

export interface ServerlessAzureProvider {
Expand Down
28 changes: 25 additions & 3 deletions src/plugins/deploy/azureDeployPlugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,29 @@ describe("Deploy plugin", () => {
expect(uploadFunctions).toBeCalledWith(functionAppStub);
});

it("lists deployments", async () => {
it("lists deployments with timestamps", async () => {
const deployments = MockFactory.createTestDeployments(5, true);
ResourceService.prototype.getDeployments = jest.fn(() => Promise.resolve(deployments));
const sls = MockFactory.createTestServerless();
const options = MockFactory.createTestServerlessOptions();
const plugin = new AzureDeployPlugin(sls, options);
await invokeHook(plugin, "deploy:list:list");
let expectedLogStatement = "\n\nDeployments";
const originalTimestamp = +MockFactory.createTestTimestamp();
let i = 0
for (const dep of deployments) {
const timestamp = originalTimestamp + i
expectedLogStatement += "\n-----------\n"
expectedLogStatement += `Name: ${dep.name}\n`
expectedLogStatement += `Timestamp: ${timestamp}\n`;
expectedLogStatement += `Datetime: ${new Date(timestamp).toISOString()}\n`
i++
}
expectedLogStatement += "-----------\n"
expect(sls.cli.log).lastCalledWith(expectedLogStatement);
});

it("lists deployments without timestamps", async () => {
const deployments = MockFactory.createTestDeployments();
ResourceService.prototype.getDeployments = jest.fn(() => Promise.resolve(deployments));
const sls = MockFactory.createTestServerless();
Expand All @@ -47,8 +69,8 @@ describe("Deploy plugin", () => {
for (const dep of deployments) {
expectedLogStatement += "\n-----------\n"
expectedLogStatement += `Name: ${dep.name}\n`
expectedLogStatement += `Timestamp: ${dep.properties.timestamp.getTime()}\n`;
expectedLogStatement += `Datetime: ${dep.properties.timestamp.toISOString()}\n`
expectedLogStatement += "Timestamp: None\n";
expectedLogStatement += "Datetime: None\n"
}
expectedLogStatement += "-----------\n"
expect(sls.cli.log).lastCalledWith(expectedLogStatement);
Expand Down
11 changes: 9 additions & 2 deletions src/plugins/deploy/azureDeployPlugin.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Serverless from "serverless";
import { FunctionAppService } from "../../services/functionAppService";
import { ResourceService } from "../../services/resourceService";
import { Utils } from "../../shared/utils";

export class AzureDeployPlugin {
public hooks: { [eventName: string]: Promise<any> };
Expand Down Expand Up @@ -45,8 +46,14 @@ export class AzureDeployPlugin {
for (const dep of deployments) {
stringDeployments += "\n-----------\n"
stringDeployments += `Name: ${dep.name}\n`
stringDeployments += `Timestamp: ${dep.properties.timestamp.getTime()}\n`;
stringDeployments += `Datetime: ${dep.properties.timestamp.toISOString()}\n`
const timestampFromName = Utils.getTimestampFromName(dep.name);
stringDeployments += `Timestamp: ${(timestampFromName) ? timestampFromName : "None"}\n`;
stringDeployments += `Datetime: ${(timestampFromName)
?
new Date(+timestampFromName).toISOString()
:
"None"
}\n`
}
stringDeployments += "-----------\n"
this.serverless.cli.log(stringDeployments);
Expand Down
1 change: 1 addition & 0 deletions src/plugins/login/loginPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export class AzureLoginPlugin {
this.hooks = {
"before:package:initialize": this.login.bind(this),
"before:deploy:list:list": this.login.bind(this),
"before:rollback:rollback": this.login.bind(this),
};
}

Expand Down
17 changes: 17 additions & 0 deletions src/plugins/rollback/azureRollbackPlugin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { MockFactory } from "../../test/mockFactory";
import { invokeHook } from "../../test/utils";
import { AzureRollbackPlugin } from "./azureRollbackPlugin";

jest.mock("../../services/rollbackService");
import { RollbackService } from "../../services/rollbackService";

describe("Rollback Plugin", () => {
it("should call rollback service", async () => {
const plugin = new AzureRollbackPlugin(
MockFactory.createTestServerless(),
MockFactory.createTestServerlessOptions(),
);
await invokeHook(plugin, "rollback:rollback");
expect(RollbackService.prototype.rollback).toBeCalled();
})
});
20 changes: 20 additions & 0 deletions src/plugins/rollback/azureRollbackPlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import Serverless from "serverless";
import { RollbackService } from "../../services/rollbackService";

/**
* Plugin for rolling back Function App Service to previous deployment
*/
export class AzureRollbackPlugin {
public hooks: { [eventName: string]: Promise<any> };

public constructor(private serverless: Serverless, private options: Serverless.Options) {
this.hooks = {
"rollback:rollback": this.rollback.bind(this)
};
}

private async rollback() {
const service = new RollbackService(this.serverless, this.options);
await service.rollback();
}
}
16 changes: 8 additions & 8 deletions src/services/armService.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import Serverless from "serverless";
import { Deployment, DeploymentExtended } from "@azure/arm-resources/esm/models";
import { BaseService } from "./baseService";
import { ResourceManagementClient } from "@azure/arm-resources";
import { Guard } from "../shared/guard";
import { ServerlessAzureConfig, ArmTemplateConfig, ServerlessAzureOptions } from "../models/serverless";
import { ArmDeployment, ArmResourceTemplateGenerator, ArmTemplateType } from "../models/armTemplates";
import { Deployment, DeploymentExtended } from "@azure/arm-resources/esm/models";
import fs from "fs";
import path from "path";
import jsonpath from "jsonpath";
import path from "path";
import Serverless from "serverless";
import { ArmDeployment, ArmResourceTemplateGenerator, ArmTemplateType } from "../models/armTemplates";
import { ArmTemplateConfig, ServerlessAzureConfig, ServerlessAzureOptions } from "../models/serverless";
import { Guard } from "../shared/guard";
import { BaseService } from "./baseService";

export class ArmService extends BaseService {
private resourceClient: ResourceManagementClient;
Expand Down Expand Up @@ -97,7 +97,7 @@ export class ArmService extends BaseService {
Object.keys(deployment.parameters).forEach((key) => {
const parameterValue = deployment.parameters[key];
if (parameterValue) {
deploymentParameters[key] = { value: deployment.parameters[key] };
deploymentParameters[key] = { value: parameterValue }
}
});

Expand Down
57 changes: 55 additions & 2 deletions src/services/azureBlobStorageService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { AzureBlobStorageService, AzureStorageAuthType } from "./azureBlobStorag

jest.mock("@azure/storage-blob");
jest.genMockFromModule("@azure/storage-blob")
import { Aborter, BlockBlobURL, ContainerURL, ServiceURL, uploadFileToBlockBlob, TokenCredential, SharedKeyCredential } from "@azure/storage-blob";
import { Aborter, BlockBlobURL, ContainerURL, ServiceURL, uploadFileToBlockBlob, TokenCredential, SharedKeyCredential, downloadBlobToBuffer, generateBlobSASQueryParameters, BlobSASPermissions } from "@azure/storage-blob";

jest.mock("@azure/arm-storage")
jest.genMockFromModule("@azure/arm-storage");
Expand All @@ -14,6 +14,9 @@ jest.mock("./loginService");
import { AzureLoginService } from "./loginService"
import { StorageAccountResource } from "../armTemplates/resources/storageAccount";

jest.mock("fs")
import fs from "fs";

describe("Azure Blob Storage Service", () => {
const filePath = "deployments/deployment.zip";
const fileName = "deployment.zip";
Expand All @@ -24,7 +27,8 @@ describe("Azure Blob Storage Service", () => {
const sls = MockFactory.createTestServerless();
const accountName = StorageAccountResource.getResourceName(sls.service as any);
const options = MockFactory.createTestServerlessOptions();
const blockBlobUrl = MockFactory.createTestBlockBlobUrl(containerName, filePath);
const blobContent = "testContent";
const blockBlobUrl = MockFactory.createTestBlockBlobUrl(containerName, filePath, blobContent);

let service: AzureBlobStorageService;
const token = "myToken";
Expand Down Expand Up @@ -130,6 +134,14 @@ describe("Azure Blob Storage Service", () => {
expect(ContainerURL.fromServiceURL).toBeCalledWith(expect.anything(), newContainerName);
expect(ContainerURL.prototype.create).toBeCalledWith(Aborter.none);
});

it("should not create a container if it exists already", async () => {
ContainerURL.fromServiceURL = jest.fn(() => new ContainerURL(null, null));
ContainerURL.prototype.create = jest.fn(() => Promise.resolve({ statusCode: 201 })) as any;
await service.createContainerIfNotExists("container1");
expect(ContainerURL.fromServiceURL).not.toBeCalled();
expect(ContainerURL.prototype.create).not.toBeCalled
})

it("should delete a container", async () => {
const containerToDelete = "delete container";
Expand All @@ -139,4 +151,45 @@ describe("Azure Blob Storage Service", () => {
expect(ContainerURL.fromServiceURL).toBeCalledWith(expect.anything(), containerToDelete);
expect(ContainerURL.prototype.delete).toBeCalledWith(Aborter.none);
});

it("should download a binary file", async () => {
const targetPath = "";
await service.downloadBinary(containerName, fileName, targetPath);
const buffer = Buffer.alloc(blobContent.length)
expect(downloadBlobToBuffer).toBeCalledWith(
Aborter.timeout(30 * 60 * 1000),
buffer,
blockBlobUrl,
0,
undefined,
{
blockSize: 4 * 1024 * 1024, // 4MB block size
parallelism: 20, // 20 concurrency
}
);
expect(fs.writeFileSync).toBeCalledWith(
targetPath,
buffer,
"binary"
)
});

it("should generate a SAS url", async () => {
const sasToken = "myToken"
BlobSASPermissions.parse = jest.fn(() => {
return {
toString: jest.fn()
}
}) as any;
(generateBlobSASQueryParameters as any).mockReturnValue(token);
const url = await service.generateBlobSasTokenUrl(containerName, fileName);
expect(generateBlobSASQueryParameters).toBeCalled();
expect(url).toEqual(`${blockBlobUrl.url}?${sasToken}`)
});

it("should throw an error when trying to get a SAS Token with Token Auth", async () => {
const newService = new AzureBlobStorageService(sls, options, AzureStorageAuthType.Token);
await newService.initialize();
await expect(newService.generateBlobSasTokenUrl(containerName, fileName)).rejects.not.toBeNull();
})
});
39 changes: 37 additions & 2 deletions src/services/azureBlobStorageService.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import { StorageAccounts, StorageManagementClientContext } from "@azure/arm-storage";
import { Aborter, BlobSASPermissions, BlockBlobURL, ContainerURL, generateBlobSASQueryParameters,SASProtocol,
ServiceURL, SharedKeyCredential, StorageURL, TokenCredential, uploadFileToBlockBlob } from "@azure/storage-blob";
import { Aborter, BlobSASPermissions, BlockBlobURL, ContainerURL,
generateBlobSASQueryParameters, SASProtocol, ServiceURL, SharedKeyCredential,
StorageURL, TokenCredential, uploadFileToBlockBlob, downloadBlobToBuffer } from "@azure/storage-blob";
import fs from "fs";
import Serverless from "serverless";
import { Guard } from "../shared/guard";
import { BaseService } from "./baseService";
import { AzureLoginService } from "./loginService";

/**
* Type of authentication with Azure Storage
* @member SharedKey - Retrieve and use a Shared Key for Azure Blob BStorage
* @member Token - Retrieve and use an Access Token to authenticate with Azure Blob Storage
*/
export enum AzureStorageAuthType {
SharedKey,
Token
Expand Down Expand Up @@ -35,6 +42,9 @@ export class AzureBlobStorageService extends BaseService {
* to perform any operation with the service
*/
public async initialize() {
if (this.storageCredential) {
return;
}
this.storageCredential = (this.authType === AzureStorageAuthType.SharedKey)
?
new SharedKeyCredential(this.storageAccountName, await this.getKey())
Expand All @@ -59,6 +69,31 @@ export class AzureBlobStorageService extends BaseService {
await uploadFileToBlockBlob(Aborter.none, path, this.getBlockBlobURL(containerName, name));
this.log("Finished uploading blob");
};

/**
* Download blob to file
* https://github.com/Azure/azure-storage-js/blob/master/blob/samples/highlevel.sample.js#L82-L97
* @param containerName Container containing blob to download
* @param blobName Blob to download
* @param targetPath Path to which blob will be downloaded
*/
public async downloadBinary(containerName: string, blobName: string, targetPath: string) {
const blockBlobUrl = this.getBlockBlobURL(containerName, blobName);
const props = await blockBlobUrl.getProperties(Aborter.none);
const buffer = Buffer.alloc(props.contentLength);
await downloadBlobToBuffer(
Aborter.timeout(30 * 60 * 1000),
buffer,
blockBlobUrl,
0,
undefined,
{
blockSize: 4 * 1024 * 1024, // 4MB block size
parallelism: 20, // 20 concurrency
}
);
fs.writeFileSync(targetPath, buffer, "binary");
}

/**
* Delete a blob from Azure Blob Storage
Expand Down
Loading

0 comments on commit cb1f295

Please sign in to comment.