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

Commit 659282e

Browse files
authored
feat: Updates to deployment process to enable rollback (Part 2) (#185)
## 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]
1 parent fd5e79a commit 659282e

12 files changed

+243
-78
lines changed

package-lock.json

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"@azure/arm-apimanagement": "^5.1.0",
4040
"@azure/arm-appservice": "^5.7.0",
4141
"@azure/arm-resources": "^1.0.1",
42+
"@azure/arm-storage": "^9.0.1",
4243
"@azure/ms-rest-nodeauth": "^1.0.1",
4344
"@azure/storage-blob": "^10.3.0",
4445
"axios": "^0.18.0",

src/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export const configConstants = {
1111
logStreamApiPath: "/api/logstream/application/functions/function/",
1212
masterKeyApiPath: "/api/functions/admin/masterkey",
1313
providerName: "azure",
14-
rollbackEnabled: false,
14+
rollbackEnabled: true,
1515
scmCommandApiPath: "/api/command",
1616
scmDomain: ".scm.azurewebsites.net",
1717
scmVfsPath: "/api/vfs/site/wwwroot/",

src/plugins/deploy/azureDeployPlugin.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@ export class AzureDeployPlugin {
5252
await resourceService.deployResourceGroup();
5353

5454
const functionAppService = new FunctionAppService(this.serverless, this.options);
55-
await functionAppService.initialize();
5655

5756
const functionApp = await functionAppService.deploy();
5857
await functionAppService.uploadFunctions(functionApp);

src/services/armService.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ describe("Arm Service", () => {
182182

183183
const expectedResourceGroup = sls.service.provider["resourceGroup"];
184184
const expectedDeploymentName = sls.service.provider["deploymentName"] || `${this.resourceGroup}-deployment`;
185+
const expectedDeploymentNameRegex = new RegExp(expectedDeploymentName + "-t([0-9]+)")
185186
const expectedDeployment: Deployment = {
186187
properties: {
187188
mode: "Incremental",
@@ -190,7 +191,10 @@ describe("Arm Service", () => {
190191
},
191192
};
192193

193-
expect(Deployments.prototype.createOrUpdate).toBeCalledWith(expectedResourceGroup, expectedDeploymentName, expectedDeployment);
194+
const call = (Deployments.prototype.createOrUpdate as any).mock.calls[0];
195+
expect(call[0]).toEqual(expectedResourceGroup);
196+
expect(call[1]).toMatch(expectedDeploymentNameRegex);
197+
expect(call[2]).toEqual(expectedDeployment);
194198
});
195199
});
196200
});

src/services/azureBlobStorageService.test.ts

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import mockFs from "mock-fs";
22
import { MockFactory } from "../test/mockFactory";
3-
import { AzureBlobStorageService } from "./azureBlobStorageService";
3+
import { AzureBlobStorageService, AzureStorageAuthType } from "./azureBlobStorageService";
44

55
jest.mock("@azure/storage-blob");
66
jest.genMockFromModule("@azure/storage-blob")
7-
import { Aborter, BlockBlobURL, ContainerURL, ServiceURL, uploadFileToBlockBlob, TokenCredential } from "@azure/storage-blob";
7+
import { Aborter, BlockBlobURL, ContainerURL, ServiceURL, uploadFileToBlockBlob, TokenCredential, SharedKeyCredential } from "@azure/storage-blob";
8+
9+
jest.mock("@azure/arm-storage")
10+
jest.genMockFromModule("@azure/arm-storage");
11+
import { StorageAccounts, StorageManagementClientContext } from "@azure/arm-storage"
812

913
jest.mock("./loginService");
1014
import { AzureLoginService } from "./loginService"
@@ -18,16 +22,27 @@ describe("Azure Blob Storage Service", () => {
1822

1923
const containers = MockFactory.createTestAzureContainers();
2024
const sls = MockFactory.createTestServerless();
25+
const accountName = "slswesdevservicenamesa";
2126
const options = MockFactory.createTestServerlessOptions();
2227
const blockBlobUrl = MockFactory.createTestBlockBlobUrl(containerName, filePath);
2328

2429
let service: AzureBlobStorageService;
2530
const token = "myToken";
31+
const keyValue = "keyValue";
2632

2733
beforeAll(() => {
28-
(TokenCredential as any).mockImplementation((token: string) => {
29-
token
30-
});
34+
(SharedKeyCredential as any).mockImplementation();
35+
(TokenCredential as any).mockImplementation();
36+
37+
StorageAccounts.prototype.listKeys = jest.fn(() => {
38+
return {
39+
keys: [
40+
{
41+
value: keyValue
42+
}
43+
]
44+
}
45+
}) as any;
3146

3247
BlockBlobURL.fromContainerURL = jest.fn(() => blockBlobUrl) as any;
3348
AzureLoginService.login = jest.fn(() => Promise.resolve({
@@ -51,8 +66,20 @@ describe("Azure Blob Storage Service", () => {
5166
mockFs.restore();
5267
});
5368

54-
beforeEach(() => {
69+
beforeEach( async () => {
5570
service = new AzureBlobStorageService(sls, options);
71+
await service.initialize();
72+
});
73+
74+
it("should initialize authentication", async () => {
75+
// Note: initialize called in beforeEach
76+
expect(SharedKeyCredential).toBeCalledWith(accountName, keyValue);
77+
expect(StorageManagementClientContext).toBeCalled();
78+
expect(StorageAccounts).toBeCalled();
79+
80+
const tokenService = new AzureBlobStorageService(sls, options, AzureStorageAuthType.Token);
81+
await tokenService.initialize();
82+
expect(TokenCredential).toBeCalled();
5683
});
5784

5885
it("should initialize authentication", async () => {
@@ -61,7 +88,7 @@ describe("Azure Blob Storage Service", () => {
6188
});
6289

6390
it("should upload a file", async () => {
64-
uploadFileToBlockBlob.prototype = jest.fn();
91+
uploadFileToBlockBlob.prototype = jest.fn(() => Promise.resolve());
6592
ContainerURL.fromServiceURL = jest.fn((serviceUrl, containerName) => (containerName as any));
6693
await service.uploadFile(filePath, containerName);
6794
expect(uploadFileToBlockBlob).toBeCalledWith(
@@ -99,7 +126,7 @@ describe("Azure Blob Storage Service", () => {
99126
ContainerURL.fromServiceURL = jest.fn(() => new ContainerURL(null, null));
100127
ContainerURL.prototype.create = jest.fn(() => Promise.resolve({ statusCode: 201 })) as any;
101128
const newContainerName = "newContainer";
102-
await service.createContainer(newContainerName);
129+
await service.createContainerIfNotExists(newContainerName);
103130
expect(ContainerURL.fromServiceURL).toBeCalledWith(expect.anything(), newContainerName);
104131
expect(ContainerURL.prototype.create).toBeCalledWith(Aborter.none);
105132
});

src/services/azureBlobStorageService.ts

Lines changed: 119 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
1-
import { Aborter, BlockBlobURL, ContainerURL, Credential, ServiceURL, StorageURL, TokenCredential, uploadFileToBlockBlob } from "@azure/storage-blob";
1+
import { StorageAccounts, StorageManagementClientContext } from "@azure/arm-storage";
2+
import { Aborter, BlobSASPermissions, BlockBlobURL, ContainerURL, generateBlobSASQueryParameters,SASProtocol,
3+
ServiceURL, SharedKeyCredential, StorageURL, TokenCredential, uploadFileToBlockBlob } from "@azure/storage-blob";
24
import Serverless from "serverless";
35
import { Guard } from "../shared/guard";
46
import { BaseService } from "./baseService";
57
import { AzureLoginService } from "./loginService";
68

9+
export enum AzureStorageAuthType {
10+
SharedKey,
11+
Token
12+
}
13+
714
/**
815
* Wrapper for operations on Azure Blob Storage account
916
*/
@@ -13,15 +20,26 @@ export class AzureBlobStorageService extends BaseService {
1320
* Account URL for Azure Blob Storage account. Depends on `storageAccountName` being set in baseService
1421
*/
1522
private accountUrl: string;
16-
private storageCredential: Credential;
23+
private authType: AzureStorageAuthType;
24+
private storageCredential: SharedKeyCredential|TokenCredential;
1725

18-
public constructor(serverless: Serverless, options: Serverless.Options) {
26+
public constructor(serverless: Serverless, options: Serverless.Options,
27+
authType: AzureStorageAuthType = AzureStorageAuthType.SharedKey) {
1928
super(serverless, options);
2029
this.accountUrl = `https://${this.storageAccountName}.blob.core.windows.net`;
30+
this.authType = authType;
2131
}
2232

33+
/**
34+
* Initialize Blob Storage service. This creates the credentials required
35+
* to perform any operation with the service
36+
*/
2337
public async initialize() {
24-
this.storageCredential = new TokenCredential(await this.getToken());
38+
this.storageCredential = (this.authType === AzureStorageAuthType.SharedKey)
39+
?
40+
new SharedKeyCredential(this.storageAccountName, await this.getKey())
41+
:
42+
new TokenCredential(await this.getToken());
2543
}
2644

2745
/**
@@ -31,11 +49,15 @@ export class AzureBlobStorageService extends BaseService {
3149
* @param blobName Name of blob file created as a result of upload
3250
*/
3351
public async uploadFile(path: string, containerName: string, blobName?: string) {
34-
Guard.empty(path);
35-
Guard.empty(containerName);
52+
Guard.empty(path, "path");
53+
Guard.empty(containerName, "containerName");
54+
this.checkInitialization();
55+
3656
// Use specified blob name or replace `/` in path with `-`
3757
const name = blobName || path.replace(/^.*[\\\/]/, "-");
38-
uploadFileToBlockBlob(Aborter.none, path, this.getBlockBlobURL(containerName, name));
58+
this.log(`Uploading file at '${path}' to container '${containerName}' with name '${name}'`)
59+
await uploadFileToBlockBlob(Aborter.none, path, this.getBlockBlobURL(containerName, name));
60+
this.log("Finished uploading blob");
3961
};
4062

4163
/**
@@ -44,8 +66,10 @@ export class AzureBlobStorageService extends BaseService {
4466
* @param blobName Blob to delete
4567
*/
4668
public async deleteFile(containerName: string, blobName: string): Promise<void> {
47-
Guard.empty(containerName);
48-
Guard.empty(blobName);
69+
Guard.empty(containerName, "containerName");
70+
Guard.empty(blobName, "blobName");
71+
this.checkInitialization();
72+
4973
const blockBlobUrl = await this.getBlockBlobURL(containerName, blobName)
5074
await blockBlobUrl.delete(Aborter.none);
5175
}
@@ -57,6 +81,8 @@ export class AzureBlobStorageService extends BaseService {
5781
*/
5882
public async listFiles(containerName: string, ext?: string): Promise<string[]> {
5983
Guard.empty(containerName, "containerName");
84+
this.checkInitialization();
85+
6086
const result: string[] = [];
6187
let marker;
6288
const containerURL = this.getContainerURL(containerName);
@@ -80,6 +106,8 @@ export class AzureBlobStorageService extends BaseService {
80106
* Lists the containers within the Azure Blob Storage account
81107
*/
82108
public async listContainers() {
109+
this.checkInitialization();
110+
83111
const result: string[] = [];
84112
let marker;
85113
do {
@@ -100,26 +128,73 @@ export class AzureBlobStorageService extends BaseService {
100128
* Creates container specified in Azure Cloud Storage options
101129
* @param containerName - Name of container to create
102130
*/
103-
public async createContainer(containerName: string): Promise<void> {
104-
Guard.empty(containerName);
105-
const containerURL = this.getContainerURL(containerName);
106-
await containerURL.create(Aborter.none);
131+
public async createContainerIfNotExists(containerName: string): Promise<void> {
132+
Guard.empty(containerName, "containerName");
133+
this.checkInitialization();
134+
135+
const containers = await this.listContainers();
136+
if (!containers.find((name) => name === containerName)) {
137+
const containerURL = this.getContainerURL(containerName);
138+
await containerURL.create(Aborter.none);
139+
}
107140
}
108141

109142
/**
110143
* Delete a container from Azure Blob Storage Account
111144
* @param containerName Name of container to delete
112145
*/
113146
public async deleteContainer(containerName: string): Promise<void> {
114-
Guard.empty(containerName);
147+
Guard.empty(containerName, "containerName");
148+
this.checkInitialization();
149+
115150
const containerUrl = await this.getContainerURL(containerName)
116151
await containerUrl.delete(Aborter.none);
117152
}
118153

154+
/**
155+
* Generate URL with SAS token for a specific blob
156+
* @param containerName Name of container containing blob
157+
* @param blobName Name of blob to generate SAS token for
158+
* @param days Number of days from current date until expiry of SAS token. Defaults to 1 year
159+
*/
160+
public async generateBlobSasTokenUrl(containerName: string, blobName: string, days: number = 365): Promise<string> {
161+
this.checkInitialization();
162+
if (this.authType !== AzureStorageAuthType.SharedKey) {
163+
throw new Error("Need to authenticate with shared key in order to generate SAS tokens. " +
164+
"Initialize Blob Service with SharedKey auth type");
165+
}
166+
167+
const now = new Date();
168+
const endDate = new Date(now);
169+
endDate.setDate(endDate.getDate() + days);
170+
171+
const blobSas = generateBlobSASQueryParameters({
172+
blobName,
173+
cacheControl: "cache-control-override",
174+
containerName,
175+
contentDisposition: "content-disposition-override",
176+
contentEncoding: "content-encoding-override",
177+
contentLanguage: "content-language-override",
178+
contentType: "content-type-override",
179+
expiryTime: endDate,
180+
ipRange: { start: "0.0.0.0", end: "255.255.255.255" },
181+
permissions: BlobSASPermissions.parse("racwd").toString(),
182+
protocol: SASProtocol.HTTPSandHTTP,
183+
startTime: now,
184+
version: "2016-05-31"
185+
},
186+
this.storageCredential as SharedKeyCredential);
187+
188+
const blobUrl = this.getBlockBlobURL(containerName, blobName);
189+
return `${blobUrl.url}?${blobSas}`
190+
}
191+
119192
/**
120193
* Get ServiceURL object for Azure Blob Storage Account
121194
*/
122195
private getServiceURL(): ServiceURL {
196+
this.checkInitialization();
197+
123198
const pipeline = StorageURL.newPipeline(this.storageCredential);
124199
const accountUrl = this.accountUrl;
125200
const serviceUrl = new ServiceURL(
@@ -135,7 +210,9 @@ export class AzureBlobStorageService extends BaseService {
135210
* @param serviceURL Previously created ServiceURL object (will create if undefined)
136211
*/
137212
private getContainerURL(containerName: string): ContainerURL {
138-
Guard.empty(containerName);
213+
Guard.empty(containerName, "containerName");
214+
this.checkInitialization();
215+
139216
return ContainerURL.fromServiceURL(
140217
this.getServiceURL(),
141218
containerName
@@ -148,19 +225,44 @@ export class AzureBlobStorageService extends BaseService {
148225
* @param blobName Name of blob
149226
*/
150227
private getBlockBlobURL(containerName: string, blobName: string): BlockBlobURL {
151-
Guard.empty(containerName);
152-
Guard.empty(blobName);
228+
Guard.empty(containerName, "containerName");
229+
Guard.empty(blobName, "blobName");
230+
this.checkInitialization();
231+
153232
return BlockBlobURL.fromContainerURL(
154233
this.getContainerURL(containerName),
155234
blobName,
156235
);
157236
}
158237

238+
/**
239+
* Get access token by logging in (again) with a storage-specific context
240+
*/
159241
private async getToken(): Promise<string> {
160242
const authResponse = await AzureLoginService.login({
161243
tokenAudience: "https://storage.azure.com/"
162244
});
163245
const token = await authResponse.credentials.getToken();
164246
return token.accessToken;
165247
}
248+
249+
/**
250+
* Get access key for storage account
251+
*/
252+
private async getKey(): Promise<string> {
253+
const context = new StorageManagementClientContext(this.credentials, this.subscriptionId)
254+
const storageAccounts = new StorageAccounts(context);
255+
const keys = await storageAccounts.listKeys(this.resourceGroup, this.storageAccountName);
256+
return keys.keys[0].value;
257+
}
258+
259+
/**
260+
* Ensure that the blob storage service has been initialized. If not initialized,
261+
* the credentials will not be available for any operation
262+
*/
263+
private checkInitialization() {
264+
Guard.null(this.storageCredential, "storageCredential",
265+
"Azure Blob Storage Service has not been initialized. Make sure .initialize() has been called " +
266+
"before performing any operation");
267+
}
166268
}

src/services/baseService.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,8 @@ describe("Base Service", () => {
6969
expect(props.subscriptionId).toEqual(sls.variables["subscriptionId"]);
7070
expect(props.serviceName).toEqual(slsConfig.service);
7171
expect(props.resourceGroup).toEqual(slsConfig.provider.resourceGroup);
72-
expect(props.deploymentName).toEqual(slsConfig.provider.deploymentName);
72+
const expectedDeploymentNameRegex = new RegExp(slsConfig.provider.deploymentName + "-t([0-9]+)")
73+
expect(props.deploymentName).toMatch(expectedDeploymentNameRegex);
7374
});
7475

7576
it("Sets default region and stage values if not defined", () => {

0 commit comments

Comments
 (0)