Skip to content

Commit

Permalink
feat: Deploy function app code from blob storage URL (#231)
Browse files Browse the repository at this point in the history
- [x] Allow configuration to specify whether or not to run from external package
- [x] Set function app setting `WEBSITE_RUN_FROM_PACKAGE` with SAS URL of package from blob storage
- [x] Get deployment configuration from `config.provider`
- [x] Upload to blob storage before deployment if `runFromBlobUrl` is specified

AB#488
  • Loading branch information
tbarlow12 authored Aug 13, 2019
1 parent c313683 commit 28d853d
Show file tree
Hide file tree
Showing 8 changed files with 145 additions and 33 deletions.
23 changes: 13 additions & 10 deletions docs/DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,23 +69,26 @@ then user try to deploy
```yml
service: my-app
provider:
...
deployment:
# Rollback enabled, deploying to blob storage
# Default is true
# If false, deploys directly to function app
rollback: true
# Container in blob storage containing deployed packages
# Default is DEPLOYMENT_ARTIFACTS
container: MY_CONTAINER_NAME
# Sets the WEBSITE_RUN_FROM_PACKAGE setting of function app
# to the SAS url of the artifact sitting in blob storage
# Recommended when using linux, not recommended when using windows
# Default is false
runFromBlobUrl: true
plugins:
- serverless-azure-functions
package:
...
deploy:
# Rollback enabled, deploying to blob storage
# Default is true
# If false, deploys directly to function app
rollback: true
# Container in blob storage containing deployed packages
# Default is DEPLOYMENT_ARTIFACTS
container: MY_CONTAINER_NAME
functions:
...
```
Expand Down
1 change: 1 addition & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const configConstants = {
funcCoreTools: "func",
funcCoreToolsArgs: ["host", "start"],
funcConsoleColor: "blue",
runFromPackageSetting: "WEBSITE_RUN_FROM_PACKAGE",
jsonContentType: "application/json",
logInvocationsApiPath: "/azurejobs/api/functions/definitions/",
logOutputApiPath: "/azurejobs/api/log/output/",
Expand Down
1 change: 1 addition & 0 deletions src/models/serverless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export interface ServerlessAzureProvider {
environment?: {
[key: string]: any;
};
deployment?: DeploymentConfig;
deploymentName?: string;
resourceGroup?: string;
apim?: ApiManagementConfig;
Expand Down
3 changes: 2 additions & 1 deletion src/plugins/login/azureLoginPlugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ describe("Login Plugin", () => {
});

it("calls login if azure credentials are not set", async () => {
unsetServicePrincipalEnvVariables();
await invokeLoginHook();
expect(AzureLoginService.interactiveLogin).toBeCalled();
expect(AzureLoginService.servicePrincipalLogin).not.toBeCalled();
Expand Down Expand Up @@ -115,7 +116,7 @@ describe("Login Plugin", () => {
expect(sls.variables["subscriptionId"]).toEqual("azureSubId");
expect(sls.cli.log).toBeCalledWith("Using subscription ID: azureSubId");
});

it("Uses the subscription ID specified in serverless yaml", async () => {
const sls = MockFactory.createTestServerless();
const opt = MockFactory.createTestServerlessOptions();
Expand Down
11 changes: 8 additions & 3 deletions src/services/baseService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export abstract class BaseService {
protected subscriptionId: string;
protected resourceGroup: string;
protected deploymentName: string;
protected artifactName: string;
protected deploymentConfig: DeploymentConfig;
protected storageAccountName: string;
protected config: ServerlessAzureConfig;
Expand All @@ -43,6 +44,7 @@ export abstract class BaseService {
this.resourceGroup = this.getResourceGroupName();
this.deploymentConfig = this.getDeploymentConfig();
this.deploymentName = this.getDeploymentName();
this.artifactName = this.getArtifactName(this.deploymentName);
this.storageAccountName = StorageAccountResource.getResourceName(this.config);

if (!this.credentials && authenticate) {
Expand Down Expand Up @@ -84,10 +86,9 @@ export abstract class BaseService {
* Defaults can be found in the `config.ts` file
*/
public getDeploymentConfig(): DeploymentConfig {
const providedConfig = this.serverless["deploy"] as DeploymentConfig;
return {
...configConstants.deploymentConfig,
...providedConfig,
...this.config.provider.deployment,
}
}

Expand Down Expand Up @@ -123,7 +124,7 @@ export abstract class BaseService {
const { deployment, artifact } = configConstants.naming.suffix;
return `${deploymentName
.replace(`rg-${deployment}`, artifact)
.replace(deployment, artifact)}`
.replace(deployment, artifact)}.zip`
}

/**
Expand Down Expand Up @@ -190,6 +191,10 @@ export abstract class BaseService {
(this.serverless.cli.log as any)(message, entity, options);
}

protected prettyPrint(object: any) {
this.log(JSON.stringify(object, null, 2));
}

/**
* Get function objects
*/
Expand Down
90 changes: 89 additions & 1 deletion src/services/functionAppService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ jest.mock("./azureBlobStorageService");
import { AzureBlobStorageService } from "./azureBlobStorageService"
import configConstants from "../config";


describe("Function App Service", () => {
const app = MockFactory.createTestSite();
const slsService = MockFactory.createTestService();
Expand All @@ -37,6 +36,11 @@ describe("Function App Service", () => {
const syncTriggersUrl = `${baseUrl}${app.id}/syncfunctiontriggers?api-version=2016-08-01`;
const listFunctionsUrl = `${baseUrl}${app.id}/functions?api-version=2016-08-01`;

const appSettings = {
setting1: "value1",
setting2: "value2",
}

beforeAll(() => {
const axiosMock = new MockAdapter(axios);

Expand Down Expand Up @@ -66,6 +70,8 @@ describe("Function App Service", () => {
WebSiteManagementClient.prototype.webApps = {
get: jest.fn(() => app),
deleteFunction: jest.fn(),
listApplicationSettings: jest.fn(() => Promise.resolve({ properties: { ...appSettings } })),
updateApplicationSettings: jest.fn(),
} as any;
(FunctionAppService.prototype as any).sendFile = jest.fn();
});
Expand Down Expand Up @@ -156,11 +162,22 @@ describe("Function App Service", () => {

beforeEach(() => {
FunctionAppService.prototype.get = jest.fn(() => Promise.resolve(expectedSite));
(FunctionAppService.prototype as any).sendFile = jest.fn();
ArmService.prototype.createDeploymentFromConfig = jest.fn(() => Promise.resolve(expectedDeployment));
ArmService.prototype.createDeploymentFromType = jest.fn(() => Promise.resolve(expectedDeployment));
ArmService.prototype.deployTemplate = jest.fn(() => Promise.resolve(null));
WebSiteManagementClient.prototype.webApps = {
get: jest.fn(() => app),
deleteFunction: jest.fn(),
listApplicationSettings: jest.fn(() => Promise.resolve({ properties: { ...appSettings } })),
updateApplicationSettings: jest.fn(),
} as any;
});

afterEach(() => {
jest.restoreAllMocks();
})

it("deploys ARM templates with custom configuration", async () => {
slsService.provider["armTemplate"] = {};

Expand Down Expand Up @@ -298,4 +315,75 @@ describe("Function App Service", () => {
const service = createService(sls, options);
expect(service.getFunctionZipFile()).toEqual("fake.zip")
});

it("adds a new function app setting", async () => {
const service = createService();
const settingName = "TEST_SETTING";
const settingValue = "TEST_VALUE"
await service.updateFunctionAppSetting(app, settingName, settingValue);
expect(WebSiteManagementClient.prototype.webApps.updateApplicationSettings).toBeCalledWith(
"myResourceGroup",
"Test",
{
...appSettings,
TEST_SETTING: settingValue
}
)
});

it("updates an existing function app setting", async () => {
const service = createService();
const settingName = "setting1";
const settingValue = "TEST_VALUE"
await service.updateFunctionAppSetting(app, settingName, settingValue);
expect(WebSiteManagementClient.prototype.webApps.updateApplicationSettings).toBeCalledWith(
"myResourceGroup",
"Test",
{
setting1: settingValue,
setting2: appSettings.setting2
}
);
});

describe("Updating Function App Settings", () => {

const sasUrl = "sasUrl"

beforeEach(() => {
FunctionAppService.prototype.updateFunctionAppSetting = jest.fn();
AzureBlobStorageService.prototype.generateBlobSasTokenUrl = jest.fn(() => Promise.resolve(sasUrl));
});

afterEach(() => {
(FunctionAppService.prototype.updateFunctionAppSetting as any).mockRestore();
});

it("updates WEBSITE_RUN_FROM_PACKAGE with SAS URL if configured to run from blob", async () => {
const newSlsService = MockFactory.createTestService();
newSlsService.provider["deployment"] = {
runFromBlobUrl: true,
}

const service = createService(MockFactory.createTestServerless({
service: newSlsService,
}));
await service.uploadFunctions(app);
expect(AzureBlobStorageService.prototype.generateBlobSasTokenUrl).toBeCalled();
expect(FunctionAppService.prototype.updateFunctionAppSetting).toBeCalledWith(
app,
"WEBSITE_RUN_FROM_PACKAGE",
sasUrl
);
});

it("does not generate SAS URL or update WEBSITE_RUN_FROM_PACKAGE if not configured to run from blob", async() => {
const service = createService();
await service.uploadFunctions(app);
expect(AzureBlobStorageService.prototype.generateBlobSasTokenUrl).not.toBeCalled();
expect(FunctionAppService.prototype.updateFunctionAppSetting).not.toBeCalled();
});
});


});
45 changes: 29 additions & 16 deletions src/services/functionAppService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@ import { FunctionAppResource } from "../armTemplates/resources/functionApp";
import { ArmDeployment } from "../models/armTemplates";
import { FunctionAppHttpTriggerConfig } from "../models/functionApp";
import { Guard } from "../shared/guard";
import { Utils } from "../shared/utils";
import { ArmService } from "./armService";
import { AzureBlobStorageService } from "./azureBlobStorageService";
import { BaseService } from "./baseService";
import { Utils } from "../shared/utils";
import configConstants from "../config";

export class FunctionAppService extends BaseService {
private static readonly retryCount: number = 10;
private static readonly retryCount: number = 30;
private static readonly retryInterval: number = 5000;
private webClient: WebSiteManagementClient;
private blobService: AzureBlobStorageService;
Expand Down Expand Up @@ -99,7 +100,7 @@ export class FunctionAppService extends BaseService {

if (listFunctionsResponse.status !== 200 || listFunctionsResponse.data.value.length === 0) {
this.log("-> Function App not ready. Retrying...");
throw new Error(listFunctionsResponse.data);
throw new Error(JSON.stringify(listFunctionsResponse.data, null, 2));
}

return listFunctionsResponse;
Expand Down Expand Up @@ -130,7 +131,7 @@ export class FunctionAppService extends BaseService {

if (getFunctionResponse.status !== 200) {
this.log("-> Function app not ready. Retrying...")
throw new Error(response.data);
throw new Error(JSON.stringify(response.data, null, 2));
}

return getFunctionResponse;
Expand All @@ -150,8 +151,21 @@ export class FunctionAppService extends BaseService {
const functionZipFile = this.getFunctionZipFile();
const uploadFunctionApp = this.uploadZippedArfifactToFunctionApp(functionApp, functionZipFile);
const uploadBlobStorage = this.uploadZippedArtifactToBlobStorage(functionZipFile);

await Promise.all([uploadFunctionApp, uploadBlobStorage]);

if (this.deploymentConfig.runFromBlobUrl) {
this.log("Updating function app setting to run from external package...");
const sasUrl = await this.blobService.generateBlobSasTokenUrl(
this.deploymentConfig.container,
this.artifactName
)
await this.updateFunctionAppSetting(
functionApp,
configConstants.runFromPackageSetting,
sasUrl
)
}

this.log("Deployed serverless functions:")
const serverlessFunctions = this.serverless.service.getAllFunctions();
Expand All @@ -178,9 +192,10 @@ export class FunctionAppService extends BaseService {
this.log(`Creating function app: ${this.serviceName}`);

const armService = new ArmService(this.serverless, this.options);
let deployment: ArmDeployment = this.config.provider.armTemplate
? await armService.createDeploymentFromConfig(this.config.provider.armTemplate)
: await armService.createDeploymentFromType(this.config.provider.type || "consumption");
const { armTemplate, type } = this.config.provider;
let deployment: ArmDeployment = armTemplate
? await armService.createDeploymentFromConfig(armTemplate)
: await armService.createDeploymentFromType(type || "consumption");

await armService.deployTemplate(deployment);

Expand Down Expand Up @@ -226,6 +241,12 @@ export class FunctionAppService extends BaseService {
return functionZipFile;
}

public async updateFunctionAppSetting(functionApp: Site, setting: string, value: string) {
const { properties } = await this.webClient.webApps.listApplicationSettings(this.resourceGroup, functionApp.name);
properties[setting] = value;
await this.webClient.webApps.updateApplicationSettings(this.resourceGroup, functionApp.name, properties);
}

/**
* Uploads artifact file to blob storage container
*/
Expand All @@ -235,18 +256,10 @@ export class FunctionAppService extends BaseService {
await this.blobService.uploadFile(
functionZipFile,
this.deploymentConfig.container,
this.getArtifactName(this.deploymentName),
this.artifactName,
);
}

/**
* Get rollback-configured artifact name. Contains `-t{timestamp}`
* if rollback is configured
*/
public getArtifactName(deploymentName: string): string {
return `${deploymentName.replace("rg-deployment", "artifact")}.zip`;
}

public getFunctionHttpTriggerConfig(functionApp: Site, functionConfig: FunctionEnvelope): FunctionAppHttpTriggerConfig {
const httpTrigger = functionConfig.config.bindings.find((binding) => {
return binding.type === "httpTrigger";
Expand Down
4 changes: 2 additions & 2 deletions src/services/rollbackService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ describe("Rollback Service", () => {
const sasURL = "sasURL";
const containerName = "deployment-artifacts";
const artifactName = MockFactory.createTestDeployment().name.replace(
configConstants.naming.suffix.deployment, configConstants.naming.suffix.artifact);
configConstants.naming.suffix.deployment, configConstants.naming.suffix.artifact) + ".zip";
const artifactPath = `.serverless${path.sep}${artifactName}`
const armDeployment: ArmDeployment = { template, parameters };
const deploymentString = "deployments";
Expand Down Expand Up @@ -115,7 +115,7 @@ describe("Rollback Service", () => {
const deploymentConfig: DeploymentConfig = {
runFromBlobUrl: true
}
sls["deploy"] = deploymentConfig;
sls.service.provider["deployment"] = deploymentConfig;
const service = createService(sls);
await service.rollback();
expect(AzureBlobStorageService.prototype.initialize).toBeCalled();
Expand Down

0 comments on commit 28d853d

Please sign in to comment.