diff --git a/package-lock.json b/package-lock.json index 98cfecab..b3cfd585 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1491,6 +1491,12 @@ "integrity": "sha512-yALhelO3i0hqZwhjtcr6dYyaLoCHbAMshwtj6cGxTvHZAKXHsYGdff6E8EPw3xLKY0ELUTQ69Q1rQiJENnccMA==", "dev": true }, + "@types/jsonpath": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@types/jsonpath/-/jsonpath-0.2.0.tgz", + "integrity": "sha512-v7qlPA0VpKUlEdhghbDqRoKMxFB3h3Ch688TApBJ6v+XLDdvWCGLJIYiPKGZnS6MAOie+IorCfNYVHOPIHSWwQ==", + "dev": true + }, "@types/lodash": { "version": "4.14.133", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.133.tgz", diff --git a/package.json b/package.json index 744b556f..fa52a26c 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "devDependencies": { "@babel/runtime": "^7.4.5", "@types/jest": "^24.0.13", + "@types/jsonpath": "^0.2.0", "@types/lodash": "^4.14.130", "@types/mock-fs": "^3.6.30", "@types/open": "^6.1.0", diff --git a/src/armTemplates/ase.ts b/src/armTemplates/ase.ts new file mode 100644 index 00000000..e79f4069 --- /dev/null +++ b/src/armTemplates/ase.ts @@ -0,0 +1,53 @@ +import { FunctionAppResource } from "./resources/functionApp"; +import { AppInsightsResource } from "./resources/appInsights"; +import { StorageAccountResource } from "./resources/storageAccount"; +import { AppServicePlanResource } from "./resources/appServicePlan"; +import { HostingEnvironmentResource } from "./resources/hostingEnvironment"; +import { VirtualNetworkResource } from "./resources/virtualNetwork"; +import { CompositeArmTemplate } from "./compositeArmTemplate"; +import { ArmResourceTemplate } from "../models/armTemplates"; + +class AppServiceEnvironmentTemplate extends CompositeArmTemplate { + public constructor() { + super([ + new FunctionAppResource(), + new AppInsightsResource(), + new StorageAccountResource(), + new AppServicePlanResource(), + new HostingEnvironmentResource(), + new VirtualNetworkResource(), + ]) + } + + public getTemplate(): ArmResourceTemplate { + const template = super.getTemplate(); + + template.parameters.appServicePlanSkuName.defaultValue = "I1"; + template.parameters.appServicePlanSkuTier.defaultValue = "Isolated"; + + // Update the app service plan to point to the hosting environment + const appServicePlan: any = template.resources.find((resource: any) => resource.type === "Microsoft.Web/serverfarms"); + if (appServicePlan) { + appServicePlan.dependsOn = [...(appServicePlan.dependsOn || []), "[resourceId('Microsoft.Web/hostingEnvironments', parameters('hostingEnvironmentName'))]"]; + appServicePlan.properties.hostingEnvironmentProfile = { + ...appServicePlan.properties.hostingEnvironmentProfile, + id: "[resourceId('Microsoft.Web/hostingEnvironments', parameters('hostingEnvironmentName'))]", + } + } + + // Update the functionApp resource to include the app service plan references + const app: any = template.resources.find((resource: any) => resource.type === "Microsoft.Web/sites"); + if (app) { + app.dependsOn = [...(app.dependsOn || []), "[concat('Microsoft.Web/serverfarms/', parameters('appServicePlanName'))]"]; + app.properties.serverFarmId = "[resourceId('Microsoft.Web/serverfarms', parameters('appServicePlanName'))]"; + app.properties.hostingEnvironmentProfile = { + ...app.properties.hostingEnvironmentProfile, + id: "[resourceId('Microsoft.Web/hostingEnvironments', parameters('hostingEnvironmentName'))]", + } + } + + return template; + } +} + +export default new AppServiceEnvironmentTemplate(); \ No newline at end of file diff --git a/src/armTemplates/compositeArmTemplate.ts b/src/armTemplates/compositeArmTemplate.ts new file mode 100644 index 00000000..a56b5474 --- /dev/null +++ b/src/armTemplates/compositeArmTemplate.ts @@ -0,0 +1,47 @@ +import { ArmResourceTemplateGenerator, ArmResourceTemplate } from "../models/armTemplates"; +import { Guard } from "../shared/guard"; +import { ServerlessAzureConfig } from "../models/serverless"; + +export class CompositeArmTemplate implements ArmResourceTemplateGenerator { + public constructor(private childTemplates: ArmResourceTemplateGenerator[]) { + Guard.null(childTemplates); + } + + public getTemplate(): ArmResourceTemplate { + const template: ArmResourceTemplate = { + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": {}, + "resources": [], + }; + + this.childTemplates.forEach((resource) => { + const resourceTemplate = resource.getTemplate(); + template.parameters = { + ...template.parameters, + ...resourceTemplate.parameters, + }; + + template.resources = [ + ...template.resources, + ...resourceTemplate.resources, + ]; + }); + + return template; + } + + public getParameters(config: ServerlessAzureConfig) { + let parameters = {}; + + this.childTemplates.forEach((resource) => { + parameters = { + ...parameters, + ...resource.getParameters(config), + location: config.provider.region, + } + }); + + return parameters; + } +} \ No newline at end of file diff --git a/src/armTemplates/consumption.ts b/src/armTemplates/consumption.ts new file mode 100644 index 00000000..05b83165 --- /dev/null +++ b/src/armTemplates/consumption.ts @@ -0,0 +1,16 @@ +import { FunctionAppResource } from "./resources/functionApp"; +import { AppInsightsResource } from "./resources/appInsights"; +import { StorageAccountResource } from "./resources/storageAccount"; +import { CompositeArmTemplate } from "./compositeArmTemplate"; + +class ConsumptionPlanTemplate extends CompositeArmTemplate { + public constructor() { + super([ + new FunctionAppResource(), + new AppInsightsResource(), + new StorageAccountResource(), + ]) + } +} + +export default new ConsumptionPlanTemplate(); \ No newline at end of file diff --git a/src/armTemplates/premium.ts b/src/armTemplates/premium.ts new file mode 100644 index 00000000..c15daa21 --- /dev/null +++ b/src/armTemplates/premium.ts @@ -0,0 +1,35 @@ +import { FunctionAppResource } from "./resources/functionApp"; +import { AppInsightsResource } from "./resources/appInsights"; +import { StorageAccountResource } from "./resources/storageAccount"; +import { AppServicePlanResource } from "./resources/appServicePlan"; +import { ArmResourceTemplate } from "../models/armTemplates"; +import { CompositeArmTemplate } from "./compositeArmTemplate"; + +class PremiumPlanTemplate extends CompositeArmTemplate { + public constructor() { + super([ + new FunctionAppResource(), + new AppInsightsResource(), + new StorageAccountResource(), + new AppServicePlanResource(), + ]) + } + + public getTemplate(): ArmResourceTemplate { + const template = super.getTemplate(); + + template.parameters.appServicePlanSkuName.defaultValue = "EP1"; + template.parameters.appServicePlanSkuTier.defaultValue = "ElasticPremium"; + + // Update the functionApp resource to include the app service plan references + const app: any = template.resources.find((resource: any) => resource.type === "Microsoft.Web/sites"); + if (app) { + app.properties.serverFarmId = "[resourceId('Microsoft.Web/serverfarms', parameters('appServicePlanName'))]"; + app.dependsOn = [...(app.dependsOn || []), "[concat('Microsoft.Web/serverfarms/', parameters('appServicePlanName'))]"] + } + + return template; + } +} + +export default new PremiumPlanTemplate(); \ No newline at end of file diff --git a/src/armTemplates/resources/apim.ts b/src/armTemplates/resources/apim.ts new file mode 100644 index 00000000..46587dbf --- /dev/null +++ b/src/armTemplates/resources/apim.ts @@ -0,0 +1,79 @@ +import { ServerlessAzureConfig } from "../../models/serverless"; +import { ArmResourceTemplateGenerator, ArmResourceTemplate } from "../../models/armTemplates"; +import { ApiManagementConfig } from "../../models/apiManagement"; + +export class ApimResource implements ArmResourceTemplateGenerator { + public static getResourceName(config: ServerlessAzureConfig) { + return config.provider.apim && config.provider.apim.name + ? config.provider.apim.name + : `${config.provider.prefix}-${config.provider.region}-${config.provider.stage}-apim`; + } + + public getTemplate(): ArmResourceTemplate { + return { + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "apiManagementName": { + "defaultValue": "", + "type": "String" + }, + "location": { + "defaultValue": "", + "type": "String" + }, + "apimSkuName": { + "defaultValue": "Consumption", + "type": "String" + }, + "apimCapacity": { + "defaultValue": 0, + "type": "int" + }, + "apimPublisherEmail": { + "defaultValue": "contact@contoso.com", + "type": "String" + }, + "apimPublisherName": { + "defaultValue": "Contoso", + "type": "String" + } + }, + "variables": {}, + "resources": [ + { + "type": "Microsoft.ApiManagement/service", + "apiVersion": "2018-06-01-preview", + "name": "[parameters('apiManagementName')]", + "location": "[parameters('location')]", + "sku": { + "name": "[parameters('apimSkuName')]", + "capacity": "[parameters('apimCapacity')]" + }, + "properties": { + "publisherEmail": "[parameters('apimPublisherEmail')]", + "publisherName": "[parameters('apimPublisherName')]", + "notificationSenderEmail": "apimgmt-noreply@mail.windowsazure.com", + "hostnameConfigurations": [], + "virtualNetworkType": "None" + } + } + ] + }; + } + + public getParameters(config: ServerlessAzureConfig) { + const apimConfig: ApiManagementConfig = { + sku: {}, + ...config.provider.apim, + }; + + return { + apiManagementName: ApimResource.getResourceName(config), + apimSkuName: apimConfig.sku.name, + apimSkuCapacity: apimConfig.sku.capacity, + apimPublisherEmail: apimConfig.publisherEmail, + apimPublisherName: apimConfig.publisherName, + }; + } +} \ No newline at end of file diff --git a/src/armTemplates/resources/appInsights.ts b/src/armTemplates/resources/appInsights.ts new file mode 100644 index 00000000..9e7df067 --- /dev/null +++ b/src/armTemplates/resources/appInsights.ts @@ -0,0 +1,47 @@ +import { ArmResourceTemplateGenerator, ArmResourceTemplate } from "../../models/armTemplates"; +import { ServerlessAzureConfig, ResourceConfig } from "../../models/serverless"; + +export class AppInsightsResource implements ArmResourceTemplateGenerator { + public static getResourceName(config: ServerlessAzureConfig) { + return config.provider.appInsights && config.provider.appInsights.name + ? config.provider.appInsights.name + : `${config.provider.prefix}-${config.provider.region}-${config.provider.stage}-appinsights`; + } + + public getTemplate(): ArmResourceTemplate { + return { + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "appInsightsName": { + "defaultValue": "", + "type": "String" + }, + "location": { + "defaultValue": "", + "type": "String" + } + }, + "variables": {}, + "resources": [ + { + "apiVersion": "2015-05-01", + "name": "[parameters('appInsightsName')]", + "type": "microsoft.insights/components", + "location": "[parameters('location')]", + "properties": { + "Application_Type": "web", + "ApplicationId": "[parameters('appInsightsName')]", + "Request_Source": "IbizaWebAppExtensionCreate" + } + } + ] + } + } + + public getParameters(config: ServerlessAzureConfig): any { + return { + appInsightsName: AppInsightsResource.getResourceName(config), + }; + } +} \ No newline at end of file diff --git a/src/armTemplates/resources/appServicePlan.ts b/src/armTemplates/resources/appServicePlan.ts new file mode 100644 index 00000000..8a6a378f --- /dev/null +++ b/src/armTemplates/resources/appServicePlan.ts @@ -0,0 +1,68 @@ +import { ArmResourceTemplateGenerator, ArmResourceTemplate } from "../../models/armTemplates"; +import { ServerlessAzureConfig, ResourceConfig } from "../../models/serverless"; + +export class AppServicePlanResource implements ArmResourceTemplateGenerator { + public static getResourceName(config: ServerlessAzureConfig) { + return config.provider.appServicePlan && config.provider.appServicePlan.name + ? config.provider.appServicePlan.name + : `${config.provider.prefix}-${config.provider.region}-${config.provider.stage}-asp`; + } + + public getTemplate(): ArmResourceTemplate { + return { + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "appServicePlanName": { + "defaultValue": "", + "type": "String" + }, + "location": { + "defaultValue": "", + "type": "String" + }, + "appServicePlanSkuName": { + "defaultValue": "EP1", + "type": "String" + }, + "appServicePlanSkuTier": { + "defaultValue": "ElasticPremium", + "type": "String" + } + }, + "variables": {}, + "resources": [ + { + "apiVersion": "2016-09-01", + "name": "[parameters('appServicePlanName')]", + "type": "Microsoft.Web/serverfarms", + "location": "[parameters('location')]", + "properties": { + "name": "[parameters('appServicePlanName')]", + "workerSizeId": "3", + "numberOfWorkers": "1", + "maximumElasticWorkerCount": "10", + "hostingEnvironment": "" + }, + "sku": { + "name": "[parameters('appServicePlanSkuName')]", + "tier": "[parameters('appServicePlanSkuTier')]" + } + } + ] + }; + } + + public getParameters(config: ServerlessAzureConfig): any { + const resourceConfig: ResourceConfig = { + sku: {}, + ...config.provider.storageAccount, + }; + + return { + appServicePlanName: AppServicePlanResource.getResourceName(config), + appServicePlanSkuName: resourceConfig.sku.name, + appServicePlanSkuTier: resourceConfig.sku.tier, + } + } +} \ No newline at end of file diff --git a/src/armTemplates/resources/functionApp.ts b/src/armTemplates/resources/functionApp.ts new file mode 100644 index 00000000..50caba93 --- /dev/null +++ b/src/armTemplates/resources/functionApp.ts @@ -0,0 +1,115 @@ +import { ArmResourceTemplateGenerator, ArmResourceTemplate } from "../../models/armTemplates"; +import { ServerlessAzureConfig, FunctionAppConfig } from "../../models/serverless"; + +export class FunctionAppResource implements ArmResourceTemplateGenerator { + public static getResourceName(config: ServerlessAzureConfig) { + return config.provider.functionApp && config.provider.functionApp.name + ? config.provider.functionApp.name + : `${config.provider.prefix}-${config.provider.region}-${config.provider.stage}-${config.service}`; + } + + public getTemplate(): ArmResourceTemplate { + return { + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "functionAppName": { + "defaultValue": "", + "type": "String" + }, + "functionAppNodeVersion": { + "defaultValue": "10.14.1", + "type": "String" + }, + "functionAppWorkerRuntime": { + "defaultValue": "node", + "type": "String" + }, + "functionAppExtensionVersion": { + "defaultValue": "~2", + "type": "String" + }, + "storageAccountName": { + "defaultValue": "", + "type": "String" + }, + "appInsightsName": { + "defaultValue": "", + "type": "String" + }, + "location": { + "defaultValue": "", + "type": "String" + } + }, + "variables": {}, + "resources": [ + { + "type": "Microsoft.Web/sites", + "apiVersion": "2016-03-01", + "name": "[parameters('functionAppName')]", + "location": "[parameters('location')]", + "dependsOn": [ + "[resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName'))]", + "[concat('microsoft.insights/components/', parameters('appInsightsName'))]" + ], + "kind": "functionapp", + "properties": { + "siteConfig": { + "appSettings": [ + { + "name": "FUNCTIONS_WORKER_RUNTIME", + "value": "[parameters('functionAppWorkerRuntime')]" + }, + { + "name": "FUNCTIONS_EXTENSION_VERSION", + "value": "[parameters('functionAppExtensionVersion')]" + }, + { + "name": "AzureWebJobsStorage", + "value": "[concat('DefaultEndpointsProtocol=https;AccountName=',parameters('storageAccountName'),';AccountKey=',listKeys(resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName')), '2016-01-01').keys[0].value)]" + }, + { + "name": "WEBSITE_CONTENTAZUREFILECONNECTIONSTRING", + "value": "[concat('DefaultEndpointsProtocol=https;AccountName=',parameters('storageAccountName'),';AccountKey=',listKeys(resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName')), '2016-01-01').keys[0].value)]" + }, + { + "name": "WEBSITE_CONTENTSHARE", + "value": "[toLower(parameters('functionAppName'))]" + }, + { + "name": "WEBSITE_NODE_DEFAULT_VERSION", + "value": "[parameters('functionAppNodeVersion')]" + }, + { + "name": "WEBSITE_RUN_FROM_PACKAGE", + "value": "1" + }, + { + "name": "APPINSIGHTS_INSTRUMENTATIONKEY", + "value": "[reference(concat('microsoft.insights/components/', parameters('appInsightsName'))).InstrumentationKey]" + } + ] + }, + "name": "[parameters('functionAppName')]", + "clientAffinityEnabled": false, + "hostingEnvironment": "" + } + } + ] + }; + } + + public getParameters(config: ServerlessAzureConfig): any { + const resourceConfig: FunctionAppConfig = { + ...config.provider.functionApp, + }; + + return { + functionAppName: FunctionAppResource.getResourceName(config), + functionAppNodeVersion: resourceConfig.nodeVersion, + functionAppWorkerRuntime: resourceConfig.workerRuntime, + functionAppExtensionVersion: resourceConfig.extensionVersion, + }; + } +} diff --git a/src/armTemplates/resources/hostingEnvironment.ts b/src/armTemplates/resources/hostingEnvironment.ts new file mode 100644 index 00000000..f7854857 --- /dev/null +++ b/src/armTemplates/resources/hostingEnvironment.ts @@ -0,0 +1,70 @@ +import { ArmResourceTemplateGenerator, ArmResourceTemplate } from "../../models/armTemplates"; +import { ServerlessAzureConfig, ResourceConfig } from "../../models/serverless"; + +export class HostingEnvironmentResource implements ArmResourceTemplateGenerator { + public static getResourceName(config: ServerlessAzureConfig) { + return config.provider.hostingEnvironment && config.provider.hostingEnvironment.name + ? config.provider.hostingEnvironment.name + : `${config.provider.prefix}-${config.provider.region}-${config.provider.stage}-ase`; + } + + public getTemplate(): ArmResourceTemplate { + return { + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "hostingEnvironmentName": { + "defaultValue": "", + "type": "String" + }, + "virtualNetworkName": { + "defaultValue": "", + "type": "String" + }, + "location": { + "defaultValue": "", + "type": "String" + } + }, + "variables": {}, + "resources": [ + { + "type": "Microsoft.Web/hostingEnvironments", + "apiVersion": "2016-09-01", + "name": "[parameters('hostingEnvironmentName')]", + "location": "[parameters('location')]", + "dependsOn": [ + "[resourceId('Microsoft.Network/virtualNetworks', parameters('virtualNetworkName'))]" + ], + "kind": "ASEV2", + "zones": [], + "properties": { + "name": "[parameters('hostingEnvironmentName')]", + "location": "[parameters('location')]", + "vnetName": "[parameters('virtualNetworkName')]", + "vnetResourceGroupName": "[resourceGroup().name]", + "vnetSubnetName": "default", + "virtualNetwork": { + "id": "[resourceId('Microsoft.Network/virtualNetworks', parameters('virtualNetworkName'))]", + "subnet": "default" + }, + "internalLoadBalancingMode": "None", + "multiSize": "Standard_D1_V2", + "multiRoleCount": 2, + "ipsslAddressCount": 2, + "networkAccessControlList": [], + "frontEndScaleFactor": 15, + "suspended": false + } + } + ] + }; + } + + public getParameters(config: ServerlessAzureConfig): any { + return { + hostingEnvironmentName: HostingEnvironmentResource.getResourceName(config) + } + } +} + diff --git a/src/armTemplates/resources/storageAccount.ts b/src/armTemplates/resources/storageAccount.ts new file mode 100644 index 00000000..b786e983 --- /dev/null +++ b/src/armTemplates/resources/storageAccount.ts @@ -0,0 +1,65 @@ +import { ArmResourceTemplateGenerator, ArmResourceTemplate } from "../../models/armTemplates"; +import { ServerlessAzureConfig, ResourceConfig } from "../../models/serverless"; + +export class StorageAccountResource implements ArmResourceTemplateGenerator { + public static getResourceName(config: ServerlessAzureConfig) { + return config.provider.storageAccount && config.provider.storageAccount.name + ? config.provider.storageAccount.name + : `${config.provider.prefix}${config.provider.region.substr(0, 3)}${config.provider.stage.substr(0, 3)}sa`.replace("-", "").toLocaleLowerCase(); + } + + public getTemplate(): ArmResourceTemplate { + return { + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "storageAccountName": { + "defaultValue": "", + "type": "String" + }, + "location": { + "defaultValue": "", + "type": "String" + }, + "storageAccountSkuName": { + "defaultValue": "Standard_LRS", + "type": "String" + }, + "storageAccoutSkuTier": { + "defaultValue": "Standard", + "type": "String" + } + }, + "variables": {}, + "resources": [ + { + "apiVersion": "2018-07-01", + "name": "[parameters('storageAccountName')]", + "type": "Microsoft.Storage/storageAccounts", + "location": "[parameters('location')]", + "kind": "Storage", + "properties": { + "accountType": "[parameters('storageAccountSkuName')]" + }, + "sku": { + "name": "[parameters('storageAccountSkuName')]", + "tier": "[parameters('storageAccoutSkuTier')]" + } + } + ] + } + } + + public getParameters(config: ServerlessAzureConfig): any { + const resourceConfig: ResourceConfig = { + sku: {}, + ...config.provider.storageAccount, + }; + + return { + storageAccountName: StorageAccountResource.getResourceName(config), + storageAccountSkuName: resourceConfig.sku.name, + storageAccoutSkuTier: resourceConfig.sku.tier, + }; + } +} \ No newline at end of file diff --git a/src/armTemplates/resources/virtualNetwork.ts b/src/armTemplates/resources/virtualNetwork.ts new file mode 100644 index 00000000..b93448b0 --- /dev/null +++ b/src/armTemplates/resources/virtualNetwork.ts @@ -0,0 +1,88 @@ +import { ArmResourceTemplateGenerator, ArmResourceTemplate } from "../../models/armTemplates"; +import { ServerlessAzureConfig, ResourceConfig } from "../../models/serverless"; + +export class VirtualNetworkResource implements ArmResourceTemplateGenerator { + public static getResourceName(config: ServerlessAzureConfig) { + return config.provider.virtualNetwork && config.provider.virtualNetwork.name + ? config.provider.virtualNetwork.name + : `${config.provider.prefix}-${config.provider.region}-${config.provider.stage}-vnet`; + } + + public getTemplate(): ArmResourceTemplate { + return { + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "hostingEnvironmentName": { + "defaultValue": "", + "type": "String" + }, + "virtualNetworkName": { + "defaultValue": "", + "type": "String" + }, + "location": { + "defaultValue": "", + "type": "String" + } + }, + "variables": {}, + "resources": [ + { + "type": "Microsoft.Network/virtualNetworks", + "apiVersion": "2018-12-01", + "name": "[parameters('virtualNetworkName')]", + "location": "[parameters('location')]", + "properties": { + "provisioningState": "Succeeded", + "resourceGuid": "b756ff30-43ac-4e83-9794-13011e7884ba", + "addressSpace": { + "addressPrefixes": [ + "172.17.0.0/16" + ] + }, + "subnets": [ + { + "name": "default", + "etag": "W/\"73e9f4aa-86a9-478e-ad11-2db211c9c2e3\"", + "properties": { + "provisioningState": "Succeeded", + "addressPrefix": "172.17.0.0/24", + "resourceNavigationLinks": [ + { + "name": "[concat('MicrosoftWeb_HostingEnvironments_', parameters('hostingEnvironmentName'))]", + "properties": { + "linkedResourceType": "Microsoft.Web/hostingEnvironments", + "link": "[resourceId('Microsoft.Web/hostingEnvironments', parameters('hostingEnvironmentName'))]" + } + } + ], + "serviceEndpoints": [ + { + "provisioningState": "Succeeded", + "service": "Microsoft.Web", + "locations": [ + "*" + ] + } + ], + "delegations": [] + } + } + ], + "virtualNetworkPeerings": [], + "enableDdosProtection": false, + "enableVmProtection": false + } + } + ] + }; + } + + public getParameters(config: ServerlessAzureConfig): any { + return { + virtualNetworkName: VirtualNetworkResource.getResourceName(config), + } + } +} + diff --git a/src/models/apiManagement.ts b/src/models/apiManagement.ts index 8c8abeac..3fc656d1 100644 --- a/src/models/apiManagement.ts +++ b/src/models/apiManagement.ts @@ -12,6 +12,12 @@ export interface ApiManagementConfig { backend?: BackendContract; /** The API's CORS policy */ cors?: ApiCorsPolicy; + sku?: { + name?: string; + capacity?: number; + }; + publisherEmail?: string; + publisherName?: string; } /** diff --git a/src/models/armTemplates.ts b/src/models/armTemplates.ts new file mode 100644 index 00000000..a35d050a --- /dev/null +++ b/src/models/armTemplates.ts @@ -0,0 +1,39 @@ +import { ServerlessAzureConfig } from "./serverless"; + +/** + * ARM Resource Template Generator + */ +export interface ArmResourceTemplateGenerator { + getTemplate(): ArmResourceTemplate; + getParameters(config: ServerlessAzureConfig): any; +} + +/** + * The well-known serverless Azure template types + */ +export enum ArmTemplateType { + Consumption = "consumption", + Premium = "premium", + AppServiceEnvironment = "ase", +} + +/** + * Represents an Azure ARM template + */ +export interface ArmResourceTemplate { + $schema: string; + contentVersion: string; + parameters: { + [key: string]: any; + }; + resources: any[]; + variables?: any; +} + +/** + * Represents an Azure ARM deployment + */ +export interface ArmDeployment { + template: ArmResourceTemplate; + parameters: { [key: string]: any }; +} diff --git a/src/models/serverless.ts b/src/models/serverless.ts index debd6df5..b3f6d315 100644 --- a/src/models/serverless.ts +++ b/src/models/serverless.ts @@ -1,7 +1,48 @@ +import { ApiManagementConfig } from "./apiManagement"; + +export interface ArmTemplateConfig { + file: string; + parameters: + { + [key: string]: string; + }; +} + +export interface ResourceConfig { + name: string; + sku?: { + name?: string; + tier?: string; + }; + [key: string]: any; +} + +export interface FunctionAppConfig extends ResourceConfig { + nodeVersion?: string; + workerRuntime?: string; + extensionVersion?; +} + export interface ServerlessAzureConfig { + service: string; provider: { + type?: string; + prefix?: string; + region: string; + stage: string; name: string; - location: string; + environment?: { + [key: string]: any; + }; + resourceGroup?: string; + apim?: ApiManagementConfig; + functionApp?: FunctionAppConfig; + appInsights?: ResourceConfig; + appServicePlan?: ResourceConfig; + storageAccount?: ResourceConfig; + hostingEnvironment?: ResourceConfig; + virtualNetwork?: ResourceConfig; + armTemplate?: ArmTemplateConfig; }; plugins: string[]; functions: any; diff --git a/src/plugins/deploy/azureDeployPlugin.test.ts b/src/plugins/deploy/azureDeployPlugin.test.ts index 3c3db2f2..f4ae441b 100644 --- a/src/plugins/deploy/azureDeployPlugin.test.ts +++ b/src/plugins/deploy/azureDeployPlugin.test.ts @@ -58,7 +58,7 @@ describe("Deploy plugin", () => { const sls = MockFactory.createTestServerless(); const resourceGroup = "rg1"; ResourceService.prototype.getDeployments = jest.fn(() => Promise.resolve([])) as any; - ResourceService.prototype.getResourceGroup = jest.fn(() => resourceGroup); + ResourceService.prototype.getResourceGroupName = jest.fn(() => resourceGroup); const options = MockFactory.createTestServerlessOptions(); const plugin = new AzureDeployPlugin(sls, options); await invokeHook(plugin, "deploy:list:list"); diff --git a/src/plugins/deploy/azureDeployPlugin.ts b/src/plugins/deploy/azureDeployPlugin.ts index 76c88ebc..485e771a 100644 --- a/src/plugins/deploy/azureDeployPlugin.ts +++ b/src/plugins/deploy/azureDeployPlugin.ts @@ -31,7 +31,7 @@ export class AzureDeployPlugin { const resourceService = new ResourceService(this.serverless, this.options); const deployments = await resourceService.getDeployments(); if (!deployments || deployments.length === 0) { - this.serverless.cli.log(`No deployments found for resource group '${resourceService.getResourceGroup()}'`); + this.serverless.cli.log(`No deployments found for resource group '${resourceService.getResourceGroupName()}'`); return; } let stringDeployments = "\n\nDeployments"; diff --git a/src/provider/armTemplates/azuredeploy.json b/src/provider/armTemplates/azuredeploy.json deleted file mode 100644 index 32df123f..00000000 --- a/src/provider/armTemplates/azuredeploy.json +++ /dev/null @@ -1,116 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "functionAppName": { - "type": "string", - "metadata": { - "description": "The name of the function app that you wish to create." - } - }, - "storageAccountType": { - "type": "string", - "defaultValue": "Standard_LRS", - "allowedValues": [ - "Standard_LRS", - "Standard_GRS", - "Standard_ZRS", - "Premium_LRS" - ], - "metadata": { - "description": "Storage Account type" - } - } - }, - "variables": { - "functionAppName": "[parameters('functionAppName')]", - "hostingPlanName": "[parameters('functionAppName')]", - "insightsName": "[concat(parameters('functionAppName'), '-insights')]", - "storageAccountName": "[concat(uniquestring(resourceGroup().id), 'azfunctions')]", - "storageAccountid": "[concat(resourceGroup().id,'/providers/','Microsoft.Storage/storageAccounts/', variables('storageAccountName'))]" - }, - "resources": [ - { - "type": "microsoft.insights/components", - "kind": "Node.JS", - "name": "[variables('insightsName')]", - "apiVersion": "2014-04-01", - "location": "eastus", - "properties": { - "ApplicationId": "[variables('insightsName')]" - } - }, - { - "type": "Microsoft.Storage/storageAccounts", - "name": "[variables('storageAccountName')]", - "apiVersion": "2015-06-15", - "location": "[resourceGroup().location]", - "properties": { - "accountType": "[parameters('storageAccountType')]" - } - }, - { - "type": "Microsoft.Web/serverfarms", - "apiVersion": "2015-04-01", - "name": "[variables('hostingPlanName')]", - "location": "[resourceGroup().location]", - "properties": { - "name": "[variables('hostingPlanName')]", - "computeMode": "Dynamic", - "sku": "Dynamic" - } - }, - { - "apiVersion": "2015-08-01", - "type": "Microsoft.Web/sites", - "name": "[variables('functionAppName')]", - "location": "[resourceGroup().location]", - "kind": "functionapp", - "dependsOn": [ - "[resourceId('Microsoft.Web/serverfarms', variables('hostingPlanName'))]", - "[resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName'))]", - "[resourceId('Microsoft.insights/components', variables('insightsName'))]" - ], - "properties": { - "clientAffinityEnabled": false, - "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', variables('hostingPlanName'))]", - "siteConfig": { - "appSettings": [ - { - "name": "APPINSIGHTS_INSTRUMENTATIONKEY", - "value": "[reference(variables('insightsName')).InstrumentationKey]" - }, - { - "name": "AzureWebJobsDashboard", - "value": "[concat('DefaultEndpointsProtocol=https;AccountName=', variables('storageAccountName'), ';AccountKey=', listKeys(variables('storageAccountid'),'2015-05-01-preview').key1)]" - }, - { - "name": "AzureWebJobsStorage", - "value": "[concat('DefaultEndpointsProtocol=https;AccountName=', variables('storageAccountName'), ';AccountKey=', listKeys(variables('storageAccountid'),'2015-05-01-preview').key1)]" - }, - { - "name": "FUNCTIONS_EXTENSION_VERSION", - "value": "~2" - }, - { - "name": "WEBSITE_CONTENTAZUREFILECONNECTIONSTRING", - "value": "[concat('DefaultEndpointsProtocol=https;AccountName=', variables('storageAccountName'), ';AccountKey=', listKeys(variables('storageAccountid'),'2015-05-01-preview').key1)]" - }, - { - "name": "WEBSITE_CONTENTSHARE", - "value": "[toLower(variables('functionAppName'))]" - }, - { - "name": "WEBSITE_NODE_DEFAULT_VERSION", - "value": "8.11.1" - }, - { - "name": "WEBSITE_RUN_FROM_PACKAGE", - "value": "1" - } - ] - } - } - } - ] -} diff --git a/src/provider/armTemplates/azuredeployWithGit.json b/src/provider/armTemplates/azuredeployWithGit.json deleted file mode 100644 index 651f9187..00000000 --- a/src/provider/armTemplates/azuredeployWithGit.json +++ /dev/null @@ -1,131 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "functionAppName": { - "type": "string", - "metadata": { - "description": "The name of the function app that you wish to create." - } - }, - "storageAccountType": { - "type": "string", - "defaultValue": "Standard_LRS", - "allowedValues": [ - "Standard_LRS", - "Standard_GRS", - "Standard_ZRS", - "Premium_LRS" - ], - "metadata": { - "description": "Storage Account type" - } - }, - "gitUrl": { - "type": "string", - "metadata": { - "description": "Git URL" - } - } - }, - "variables": { - "functionAppName": "[parameters('functionAppName')]", - "hostingPlanName": "[parameters('functionAppName')]", - "insightsName": "[concat(parameters('functionAppName'), '-insights')]", - "storageAccountName": "[concat(uniquestring(resourceGroup().id), 'azfunctions')]", - "storageAccountid": "[concat(resourceGroup().id,'/providers/','Microsoft.Storage/storageAccounts/', variables('storageAccountName'))]", - "gitRepoUrl": "[parameters('gitUrl')]" - }, - "resources": [ - { - "type": "microsoft.insights/components", - "kind": "Node.JS", - "name": "[variables('insightsName')]", - "apiVersion": "2014-04-01", - "location": "eastus", - "properties": { - "ApplicationId": "[variables('insightsName')]" - } - }, - { - "type": "Microsoft.Storage/storageAccounts", - "name": "[variables('storageAccountName')]", - "apiVersion": "2015-06-15", - "location": "[resourceGroup().location]", - "properties": { - "accountType": "[parameters('storageAccountType')]" - } - }, - { - "type": "Microsoft.Web/serverfarms", - "apiVersion": "2015-04-01", - "name": "[variables('hostingPlanName')]", - "location": "[resourceGroup().location]", - "properties": { - "name": "[variables('hostingPlanName')]", - "computeMode": "Dynamic", - "sku": "Dynamic" - } - }, - { - "apiVersion": "2015-08-01", - "name": "web", - "type": "sourcecontrols", - "dependsOn": [ - "[resourceId('Microsoft.Web/sites', variables('functionAppName'))]" - ], - "properties": { - "repoUrl": "[variables('gitRepoUrl')]", - "IsManualIntegration": true - } - }, - { - "apiVersion": "2015-08-01", - "type": "Microsoft.Web/sites", - "name": "[variables('functionAppName')]", - "location": "[resourceGroup().location]", - "kind": "functionapp", - "dependsOn": [ - "[resourceId('Microsoft.Web/serverfarms', variables('hostingPlanName'))]", - "[resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName'))]", - "[resourceId('Microsoft.insights/components', variables('insightsName'))]" - ], - "properties": { - "clientAffinityEnabled": false, - "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', variables('hostingPlanName'))]", - "siteConfig": { - "appSettings": [ - { - "name": "APPINSIGHTS_INSTRUMENTATIONKEY", - "value": "[reference(variables('insightsName')).InstrumentationKey]" - }, - { - "name": "AzureWebJobsDashboard", - "value": "[concat('DefaultEndpointsProtocol=https;AccountName=', variables('storageAccountName'), ';AccountKey=', listKeys(variables('storageAccountid'),'2015-05-01-preview').key1)]" - }, - { - "name": "AzureWebJobsStorage", - "value": "[concat('DefaultEndpointsProtocol=https;AccountName=', variables('storageAccountName'), ';AccountKey=', listKeys(variables('storageAccountid'),'2015-05-01-preview').key1)]" - }, - { - "name": "FUNCTIONS_EXTENSION_VERSION", - "value": "~1" - }, - { - "name": "WEBSITE_CONTENTAZUREFILECONNECTIONSTRING", - "value": "[concat('DefaultEndpointsProtocol=https;AccountName=', variables('storageAccountName'), ';AccountKey=', listKeys(variables('storageAccountid'),'2015-05-01-preview').key1)]" - }, - { - "name": "WEBSITE_CONTENTSHARE", - "value": "[toLower(variables('functionAppName'))]" - }, - { - "name": "WEBSITE_NODE_DEFAULT_VERSION", - "value": "6.5.0" - } - ] - } - } - } - ] -} diff --git a/src/services/apimService.test.ts b/src/services/apimService.test.ts index 1b14fe4b..028b8ec4 100644 --- a/src/services/apimService.test.ts +++ b/src/services/apimService.test.ts @@ -1,7 +1,6 @@ import Serverless from "serverless"; import _ from "lodash"; import { MockFactory } from "../test/mockFactory"; -import { ApiManagementConfig } from "../models/apiManagement"; import { ApimService } from "./apimService"; import { interpolateJson } from "../test/utils"; import axios from "axios"; @@ -22,18 +21,7 @@ import { } from "@azure/arm-apimanagement/esm/models"; describe("APIM Service", () => { - const apimConfig: ApiManagementConfig = { - name: "test-apim-resource", - api: { - name: "test-apim-api1", - subscriptionRequired: false, - displayName: "API 1", - description: "description of api 1", - protocols: ["https"], - path: "test-api1", - }, - }; - + const apimConfig = MockFactory.createTestApimConfig(); let serverless: Serverless; beforeEach(() => { diff --git a/src/services/apimService.ts b/src/services/apimService.ts index a6452f95..6126a70c 100644 --- a/src/services/apimService.ts +++ b/src/services/apimService.ts @@ -17,18 +17,22 @@ import { Guard } from "../shared/guard"; export class ApimService extends BaseService { private apimClient: ApiManagementClient; private functionAppService: FunctionAppService; - private config: ApiManagementConfig; + private apimConfig: ApiManagementConfig; public constructor(serverless: Serverless, options?: Serverless.Options) { super(serverless, options); - this.config = this.serverless.service.provider["apim"]; - if (!this.config) { + this.apimConfig = this.config.provider.apim; + if (!this.apimConfig) { return; } - if (!this.config.backend) { - this.config.backend = {} as any; + if (!this.apimConfig.name) { + this.apimConfig.name = `${this.config.provider.prefix}-${this.config.provider.region}-${this.config.provider.stage}-apim` + } + + if (!this.apimConfig.backend) { + this.apimConfig.backend = {} as any; } this.apimClient = new ApiManagementClient(this.credentials, this.subscriptionId); @@ -39,24 +43,24 @@ export class ApimService extends BaseService { * Gets the configured APIM resource */ public async get(): Promise { - if (!(this.config && this.config.name)) { + if (!(this.apimConfig && this.apimConfig.name)) { return null; } try { - return await this.apimClient.apiManagementService.get(this.resourceGroup, this.config.name); + return await this.apimClient.apiManagementService.get(this.resourceGroup, this.apimConfig.name); } catch (err) { return null; } } public async getApi(): Promise { - if (!(this.config && this.config.api && this.config.api.name)) { + if (!(this.apimConfig && this.apimConfig.api && this.apimConfig.api.name)) { return null; } try { - return await this.apimClient.api.get(this.resourceGroup, this.config.name, this.config.api.name); + return await this.apimClient.api.get(this.resourceGroup, this.apimConfig.name, this.apimConfig.api.name); } catch (err) { return null; } @@ -66,7 +70,7 @@ export class ApimService extends BaseService { * Deploys the APIM top level api */ public async deployApi() { - if (!(this.config && this.config.name)) { + if (!(this.apimConfig && this.apimConfig.name)) { return null; } @@ -86,7 +90,7 @@ export class ApimService extends BaseService { Guard.null(service); Guard.null(api); - if (!(this.config && this.config.name)) { + if (!(this.apimConfig && this.apimConfig.name)) { return null; } @@ -131,21 +135,21 @@ export class ApimService extends BaseService { this.log("-> Deploying API"); try { - const api = await this.apimClient.api.createOrUpdate(this.resourceGroup, this.config.name, this.config.api.name, { + const api = await this.apimClient.api.createOrUpdate(this.resourceGroup, this.apimConfig.name, this.apimConfig.api.name, { isCurrent: true, - subscriptionRequired: this.config.api.subscriptionRequired, - displayName: this.config.api.displayName, - description: this.config.api.description, - path: this.config.api.path, - protocols: this.config.api.protocols, + subscriptionRequired: this.apimConfig.api.subscriptionRequired, + displayName: this.apimConfig.api.displayName, + description: this.apimConfig.api.description, + path: this.apimConfig.api.path, + protocols: this.apimConfig.api.protocols, }); - if (this.config.cors) { + if (this.apimConfig.cors) { this.log("-> Deploying CORS policy"); - await this.apimClient.apiPolicy.createOrUpdate(this.resourceGroup, this.config.name, this.config.api.name, { + await this.apimClient.apiPolicy.createOrUpdate(this.resourceGroup, this.apimConfig.name, this.apimConfig.api.name, { format: "rawxml", - value: this.createCorsXmlPolicy(this.config.cors) + value: this.createCorsXmlPolicy(this.apimConfig.cors) }); } @@ -168,17 +172,17 @@ export class ApimService extends BaseService { try { const functionAppResourceId = `https://management.azure.com${functionApp.id}`; - return await this.apimClient.backend.createOrUpdate(this.resourceGroup, this.config.name, this.serviceName, { + return await this.apimClient.backend.createOrUpdate(this.resourceGroup, this.apimConfig.name, this.serviceName, { credentials: { header: { "x-functions-key": [`{{${this.serviceName}-key}}`], }, }, - title: this.config.backend.title || functionApp.name, - tls: this.config.backend.tls, - proxy: this.config.backend.proxy, - description: this.config.backend.description, - protocol: this.config.backend.protocol || "http", + title: this.apimConfig.backend.title || functionApp.name, + tls: this.apimConfig.backend.tls, + proxy: this.apimConfig.backend.proxy, + description: this.apimConfig.backend.description, + protocol: this.apimConfig.backend.protocol || "http", resourceId: functionAppResourceId, url: backendUrl, }); @@ -216,13 +220,13 @@ export class ApimService extends BaseService { const operation = await client.apiOperation.createOrUpdate( this.resourceGroup, - this.config.name, - this.config.api.name, + this.apimConfig.name, + this.apimConfig.api.name, options.function, operationConfig, ); - await client.apiOperationPolicy.createOrUpdate(this.resourceGroup, this.config.name, this.config.api.name, options.function, { + await client.apiOperationPolicy.createOrUpdate(this.resourceGroup, this.apimConfig.name, this.apimConfig.api.name, options.function, { format: "rawxml", value: this.createApiOperationXmlPolicy(), }); @@ -245,7 +249,7 @@ export class ApimService extends BaseService { const masterKey = await this.functionAppService.getMasterKey(functionApp); const keyName = `${this.serviceName}-key`; - return await this.apimClient.property.createOrUpdate(this.resourceGroup, this.config.name, keyName, { + return await this.apimClient.property.createOrUpdate(this.resourceGroup, this.apimConfig.name, keyName, { displayName: keyName, secret: true, value: masterKey, diff --git a/src/services/armService.test.ts b/src/services/armService.test.ts new file mode 100644 index 00000000..b61c48cf --- /dev/null +++ b/src/services/armService.test.ts @@ -0,0 +1,196 @@ +import Serverless from "serverless"; +import { MockFactory } from "../test/mockFactory"; +import { ArmService } from "./armService"; +import { ArmResourceTemplate, ArmTemplateType } from "../models/armTemplates"; +import { ArmTemplateConfig } from "../models/serverless"; +import mockFs from "mock-fs"; +import jsonpath from "jsonpath"; +import { Deployments } from "@azure/arm-resources"; +import { Deployment } from "@azure/arm-resources/esm/models"; + +describe("Arm Service", () => { + let sls: Serverless + let service: ArmService; + + function createService() { + return new ArmService(sls); + } + + beforeEach(() => { + sls = MockFactory.createTestServerless(); + sls.service.provider["prefix"] = "myapp"; + sls.service.provider.region = "westus"; + sls.service.provider.stage = "dev"; + sls.variables = { + ...sls.variables, + azureCredentials: MockFactory.createTestAzureCredentials(), + subscriptionId: "ABC123", + }; + + service = createService(); + }) + + afterEach(() => { + mockFs.restore(); + }) + + describe("Creating Templates", () => { + it("Creates an ARM template from a specified file", async () => { + const armTemplateConfig: ArmTemplateConfig = { + file: "armTemplates/custom-template.json", + parameters: { + param1: "1", + param2: "2", + }, + }; + + const testTemplate: ArmResourceTemplate = MockFactory.createTestArmTemplate(); + + mockFs({ + "armTemplates": { + "custom-template.json": JSON.stringify(testTemplate), + }, + }); + + sls.service.provider["armTemplate"] = armTemplateConfig; + const deployment = await service.createDeploymentFromConfig(sls.service.provider["armTemplate"]); + + expect(deployment).not.toBeNull(); + expect(deployment.template.parameters).toEqual(testTemplate.parameters); + expect(deployment.template.resources).toEqual(testTemplate.resources); + expect(deployment.parameters).toEqual(armTemplateConfig.parameters); + }); + + it("Creates a custom ARM template from well-known type", async () => { + const deployment = await service.createDeploymentFromType("premium"); + + expect(deployment).not.toBeNull(); + expect(Object.keys(deployment.parameters).length).toBeGreaterThan(0); + expect(deployment.template.resources.length).toBeGreaterThan(0); + }); + + it("Creates a custom ARM template (with APIM support) from well-known type", async () => { + sls.service.provider["apim"] = MockFactory.createTestApimConfig(); + const deployment = await service.createDeploymentFromType(ArmTemplateType.Premium); + + expect(deployment).not.toBeNull(); + expect(Object.keys(deployment.parameters).length).toBeGreaterThan(0); + expect(deployment.template.resources.length).toBeGreaterThan(0); + + expect(deployment.template.resources.find((resource) => resource.type === "Microsoft.ApiManagement/service")).not.toBeNull(); + }); + + it("throws error when specified type is not found", async () => { + await expect(service.createDeploymentFromType("not-found")).rejects.not.toBeNull(); + }); + + it("Premium template includes correct resources", async () => { + const deployment = await service.createDeploymentFromType(ArmTemplateType.Premium); + + expect(deployment.template.parameters.appServicePlanSkuTier.defaultValue).toEqual("ElasticPremium"); + expect(deployment.template.parameters.appServicePlanSkuName.defaultValue).toEqual("EP1"); + + // Should not contain + expect(deployment.template.resources.find((resource) => resource.type === "Microsoft.Web/hostingEnvironments")).toBeUndefined(); + expect(deployment.template.resources.find((resource) => resource.type === "Microsoft.Network/virtualNetworks")).toBeUndefined(); + + // Should contain + expect(deployment.template.resources.find((resource) => resource.type === "Microsoft.Web/serverfarms")).not.toBeNull(); + expect(deployment.template.resources.find((resource) => resource.type === "Microsoft.Web/sites")).not.toBeNull(); + expect(deployment.template.resources.find((resource) => resource.type === "Microsoft.Storage/storageAccounts")).not.toBeNull(); + expect(deployment.template.resources.find((resource) => resource.type === "microsoft.insights/components")).not.toBeNull(); + + // Verify the ARM template includes the linkage to the correct server farm + const functionApp = deployment.template.resources.find((res) => res.type === "Microsoft.Web/sites"); + expect(functionApp.dependsOn).toContain("[concat('Microsoft.Web/serverfarms/', parameters('appServicePlanName'))]"); + expect(functionApp.properties.serverFarmId).toEqual("[resourceId('Microsoft.Web/serverfarms', parameters('appServicePlanName'))]"); + }); + + it("ASE template includes correct resources", async () => { + const deployment = await service.createDeploymentFromType(ArmTemplateType.AppServiceEnvironment); + + expect(deployment.template.parameters.appServicePlanSkuTier.defaultValue).toEqual("Isolated"); + expect(deployment.template.parameters.appServicePlanSkuName.defaultValue).toEqual("I1"); + + expect(deployment.template.resources.find((resource) => resource.type === "Microsoft.Web/hostingEnvironments")).not.toBeNull(); + expect(deployment.template.resources.find((resource) => resource.type === "Microsoft.Network/virtualNetworks")).not.toBeNull(); + expect(deployment.template.resources.find((resource) => resource.type === "Microsoft.Web/serverfarms")).not.toBeNull(); + expect(deployment.template.resources.find((resource) => resource.type === "Microsoft.Web/sites")).not.toBeNull(); + expect(deployment.template.resources.find((resource) => resource.type === "Microsoft.Storage/storageAccounts")).not.toBeNull(); + expect(deployment.template.resources.find((resource) => resource.type === "microsoft.insights/components")).not.toBeNull(); + + // Verify the ARM template includes the linkage to the correct server farm + const appServicePlan = deployment.template.resources.find((res) => res.type === "Microsoft.Web/serverfarms"); + expect(appServicePlan.dependsOn).toContain("[resourceId('Microsoft.Web/hostingEnvironments', parameters('hostingEnvironmentName'))]"); + expect(appServicePlan.properties.hostingEnvironmentProfile.id).toEqual("[resourceId('Microsoft.Web/hostingEnvironments', parameters('hostingEnvironmentName'))]"); + + // Verify the ARM template includes the linkage to the correct hosting environment + const functionApp = deployment.template.resources.find((res) => res.type === "Microsoft.Web/sites"); + expect(functionApp.dependsOn).toContain("[concat('Microsoft.Web/serverfarms/', parameters('appServicePlanName'))]"); + expect(functionApp.properties.serverFarmId).toEqual("[resourceId('Microsoft.Web/serverfarms', parameters('appServicePlanName'))]"); + expect(functionApp.properties.hostingEnvironmentProfile.id).toEqual("[resourceId('Microsoft.Web/hostingEnvironments', parameters('hostingEnvironmentName'))]"); + }); + + it("Consumption template includes correct resources", async () => { + const deployment = await service.createDeploymentFromType(ArmTemplateType.Consumption); + + expect(deployment.template.resources.find((resource) => resource.type === "Microsoft.Web/hostingEnvironments")).toBeUndefined(); + expect(deployment.template.resources.find((resource) => resource.type === "Microsoft.Network/virtualNetworks")).toBeUndefined(); + expect(deployment.template.resources.find((resource) => resource.type === "Microsoft.Web/serverfarms")).toBeUndefined(); + + // Should contain + expect(deployment.template.resources.find((resource) => resource.type === "Microsoft.Web/sites")).not.toBeNull(); + expect(deployment.template.resources.find((resource) => resource.type === "Microsoft.Storage/storageAccounts")).not.toBeNull(); + expect(deployment.template.resources.find((resource) => resource.type === "microsoft.insights/components")).not.toBeNull(); + }); + }); + + describe("Deploying Templates", () => { + beforeEach(() => { + Deployments.prototype.createOrUpdate = jest.fn(() => Promise.resolve(null)); + }); + + it("Appends environment variables into app settings of ARM template", async () => { + const environmentConfig: any = { + PARAM_1: "1", + PARAM_2: "2", + PARAM_3: "3", + }; + + sls.service.provider["environment"] = environmentConfig + + const deployment = await service.createDeploymentFromType(ArmTemplateType.Consumption); + await service.deployTemplate(deployment); + + const appSettings: any[] = jsonpath.query(deployment.template, "$.resources[?(@.kind==\"functionapp\")].properties.siteConfig.appSettings[*]"); + expect(appSettings.find((setting) => setting.name === "PARAM_1")).toEqual({ name: "PARAM_1", value: environmentConfig.PARAM_1 }); + expect(appSettings.find((setting) => setting.name === "PARAM_2")).toEqual({ name: "PARAM_2", value: environmentConfig.PARAM_2 }); + expect(appSettings.find((setting) => setting.name === "PARAM_3")).toEqual({ name: "PARAM_3", value: environmentConfig.PARAM_3 }); + }); + + it("Deploys ARM template via resources REST API", async () => { + const deployment = await service.createDeploymentFromType(ArmTemplateType.Consumption); + const deploymentParameters = {}; + Object.keys(deployment.parameters).forEach((key) => { + const parameterValue = deployment.parameters[key]; + if (parameterValue) { + deploymentParameters[key] = { value: deployment.parameters[key] }; + } + }); + + await service.deployTemplate(deployment); + + const expectedResourceGroup = sls.service.provider["resourceGroup"]; + const expectedDeploymentName = sls.service.provider["deploymentName"] || `${this.resourceGroup}-deployment`; + const expectedDeployment: Deployment = { + properties: { + mode: "Incremental", + template: deployment.template, + parameters: deploymentParameters, + }, + }; + + expect(Deployments.prototype.createOrUpdate).toBeCalledWith(expectedResourceGroup, expectedDeploymentName, expectedDeployment); + }); + }); +}); \ No newline at end of file diff --git a/src/services/armService.ts b/src/services/armService.ts new file mode 100644 index 00000000..ce13d0c3 --- /dev/null +++ b/src/services/armService.ts @@ -0,0 +1,149 @@ +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 } from "../models/serverless"; +import { ArmDeployment, ArmResourceTemplateGenerator, ArmTemplateType } from "../models/armTemplates"; +import fs from "fs"; +import path from "path"; +import jsonpath from "jsonpath"; + +export class ArmService extends BaseService { + private resourceClient: ResourceManagementClient; + + public constructor(serverless: Serverless) { + super(serverless); + this.resourceClient = new ResourceManagementClient(this.credentials, this.subscriptionId); + } + + /** + * Creates an ARM deployment from a well-known configuration (consumption, premium, ase) + * @param type The well-known template type + */ + public async createDeploymentFromType(type: ArmTemplateType | string): Promise { + Guard.empty(type); + + this.log(`-> Creating ARM template from type: ${type}`); + + const { ApimResource } = await import("../armTemplates/resources/apim"); + const apimResource = new ApimResource(); + let template: ArmResourceTemplateGenerator; + + try { + template = (await import(`../armTemplates/${type}`)).default; + } catch (e) { + throw new Error(`Unable to find template with name ${type} `); + } + + const azureConfig: ServerlessAzureConfig = this.serverless.service as any; + + const mergedTemplate = template.getTemplate(); + let parameters = template.getParameters(azureConfig); + + if (this.config.provider.apim) { + const apimTemplate = apimResource.getTemplate(); + const apimParameters = apimResource.getParameters(azureConfig); + + mergedTemplate.parameters = { + ...mergedTemplate.parameters, + ...apimTemplate.parameters, + }; + mergedTemplate.resources = [ + ...mergedTemplate.resources, + ...apimTemplate.resources, + ]; + + parameters = { + ...parameters, + ...apimParameters, + }; + } + + return { + template: mergedTemplate, + parameters, + }; + } + + /** + * Creates an ARM deployment from the serverless custom yaml configuration + * @param armTemplateConfig The serverless yaml ARM template configuration + */ + public createDeploymentFromConfig(armTemplateConfig: ArmTemplateConfig): Promise { + Guard.null(armTemplateConfig); + + this.log(`-> Creating ARM template from file: ${armTemplateConfig.file}`); + const templateFilePath = path.join(this.serverless.config.servicePath, armTemplateConfig.file); + const template = JSON.parse(fs.readFileSync(templateFilePath, "utf8")); + + return Promise.resolve({ + template, + parameters: armTemplateConfig.parameters + }); + } + + /** + * Deploys the specified ARM template to Azure via REST service call + * @param deployment The ARM template to deploy + */ + public async deployTemplate(deployment: ArmDeployment): Promise { + Guard.null(deployment); + + this.applyEnvironmentVariables(deployment); + + // Convert flat parameter list into ARM parameter format + const deploymentParameters = {}; + Object.keys(deployment.parameters).forEach((key) => { + const parameterValue = deployment.parameters[key]; + if (parameterValue) { + deploymentParameters[key] = { value: deployment.parameters[key] }; + } + }); + + // Construct deployment object + const armDeployment: Deployment = { + properties: { + mode: "Incremental", + template: deployment.template, + parameters: deploymentParameters, + } + }; + + // Deploy ARM template + this.serverless.cli.log("-> Deploying ARM template..."); + const result = await this.resourceClient.deployments.createOrUpdate(this.resourceGroup, this.deploymentName, armDeployment); + this.serverless.cli.log("-> ARM deployment complete"); + + return result; + } + + /** + * Applies sls yaml environment variables into the appSettings section of the function app configuration + * @param deployment The ARM deployment + */ + private applyEnvironmentVariables(deployment: ArmDeployment) { + // Check if there are custom environment variables defined that need to be + // added to the ARM template used in the deployment. + const environmentVariables = this.config.provider.environment; + if (environmentVariables) { + this.serverless.cli.log("-> Merging environment configuration"); + + // This is a json path expression + // Learn more @ https://goessner.net/articles/JsonPath/index.html#e2 + const appSettingsPath = "$.resources[?(@.kind==\"functionapp\")].properties.siteConfig.appSettings"; + + // Merges serverless yaml environment configuration into the app settings of the template + jsonpath.apply(deployment.template, appSettingsPath, function (appSettingsList) { + Object.keys(environmentVariables).forEach(function (key) { + appSettingsList.push({ + name: key, + value: environmentVariables[key] + }); + }); + + return appSettingsList; + }); + } + } +} \ No newline at end of file diff --git a/src/services/baseService.test.ts b/src/services/baseService.test.ts index ac3f65a2..f4c83da8 100644 --- a/src/services/baseService.test.ts +++ b/src/services/baseService.test.ts @@ -21,18 +21,6 @@ class MockService extends BaseService { return this.sendFile(requestOptions, filePath); } - public getSlsResourceGroupName() { - return this.getResourceGroupName(); - } - - public getSlsRegion() { - return this.getRegion(); - } - - public getSlsStage() { - return this.getStage(); - } - public getProperties() { return { baseUrl: this.baseUrl, @@ -61,7 +49,7 @@ describe("Base Service", () => { mockFs.restore(); }); - function createTestService(options?: Serverless.Options) { + function createMockService(options?: Serverless.Options) { sls = MockFactory.createTestServerless(); sls.variables["azureCredentials"] = MockFactory.createTestAzureCredentials(); sls.variables["subscriptionId"] = "ABC123"; @@ -71,7 +59,7 @@ describe("Base Service", () => { } beforeEach(() => { - service = createTestService(); + service = createMockService(); }); it("Initializes common service properties", () => { @@ -85,9 +73,28 @@ describe("Base Service", () => { }); it("Sets default region and stage values if not defined", () => { - const testService = new MockService(sls); + const mockService = new MockService(sls); + + expect(mockService).not.toBeNull(); + expect(sls.service.provider.region).toEqual("westus"); + expect(sls.service.provider.stage).toEqual("dev"); + }); + + it("returns region and stage based on CLI options", () => { + const cliOptions = { + stage: "prod", + region: "eastus2" + }; + const mockService = new MockService(sls, cliOptions); + + expect(mockService.getRegion()).toEqual(cliOptions.region); + expect(mockService.getStage()).toEqual(cliOptions.stage); + }); + + it("Sets default region and stage values if not defined", () => { + const mockService = new MockService(sls); - expect(testService).not.toBeNull(); + expect(mockService).not.toBeNull(); expect(sls.service.provider.region).toEqual("westus"); expect(sls.service.provider.stage).toEqual("dev"); }); @@ -97,27 +104,27 @@ describe("Base Service", () => { stage: "prod", region: "eastus2" }; - const testService = new MockService(sls, cliOptions); + const mockService = new MockService(sls, cliOptions); - expect(testService.getSlsRegion()).toEqual(cliOptions.region); - expect(testService.getSlsStage()).toEqual(cliOptions.stage); + expect(mockService.getRegion()).toEqual(cliOptions.region); + expect(mockService.getStage()).toEqual(cliOptions.stage); }); it("Generates resource group name from sls yaml config", () => { - const testService = new MockService(sls); - const resourceGroupName = testService.getSlsResourceGroupName(); + const mockService = new MockService(sls); + const resourceGroupName = mockService.getResourceGroupName(); expect(resourceGroupName).toEqual(sls.service.provider["resourceGroup"]); }); it("Generates resource group from convention when NOT defined in sls yaml", () => { sls.service.provider["resourceGroup"] = null; - const testService = new MockService(sls); - const resourceGroupName = testService.getSlsResourceGroupName(); - const region = testService.getSlsRegion(); - const stage = testService.getSlsStage(); + const mockService = new MockService(sls); + const resourceGroupName = mockService.getResourceGroupName(); + const region = mockService.getRegion(); + const stage = mockService.getStage(); - expect(resourceGroupName).toEqual(`${sls.service["service"]}-${region}-${stage}-rg`); + expect(resourceGroupName).toEqual(`sls-${region}-${stage}-${sls.service["service"]}-rg`); }); it("Fails if credentials have not been set in serverless config", () => { diff --git a/src/services/baseService.ts b/src/services/baseService.ts index 4a81638e..44b42310 100644 --- a/src/services/baseService.ts +++ b/src/services/baseService.ts @@ -3,6 +3,7 @@ import fs from "fs"; import request from "request"; import Serverless from "serverless"; import { Guard } from "../shared/guard"; +import { ServerlessAzureConfig } from "../models/serverless"; export abstract class BaseService { protected baseUrl: string; @@ -11,6 +12,7 @@ export abstract class BaseService { protected subscriptionId: string; protected resourceGroup: string; protected deploymentName: string; + protected config: ServerlessAzureConfig; protected constructor( protected serverless: Serverless, @@ -19,24 +21,35 @@ export abstract class BaseService { ) { Guard.null(serverless); + this.setDefaultValues(); + this.baseUrl = "https://management.azure.com"; + this.config = serverless.service as any; this.serviceName = serverless.service["service"]; this.credentials = serverless.variables["azureCredentials"]; this.subscriptionId = serverless.variables["subscriptionId"]; this.resourceGroup = this.getResourceGroupName(); this.deploymentName = serverless.service.provider["deploymentName"] || `${this.resourceGroup}-deployment`; - this.setDefaultValues(); - if (!this.credentials && authenticate) { throw new Error(`Azure Credentials has not been set in ${this.constructor.name}`); } } - public getResourceGroup(): string { - return this.resourceGroup; + public getRegion(): string { + return this.options.region || this.serverless.service.provider.region; } + public getStage(): string { + return this.options.stage || this.serverless.service.provider.stage; + } + + public getResourceGroupName(): string { + return this.options["resourceGroup"] + || this.serverless.service.provider["resourceGroup"] + || `${this.config.provider.prefix}-${this.getRegion()}-${this.getStage()}-${this.serviceName}-rg`; + } + /** * Sends an API request using axios HTTP library * @param method The HTTP method @@ -105,19 +118,9 @@ export abstract class BaseService { if (!this.serverless.service.provider.stage) { this.serverless.service.provider.stage = "dev"; } - } - - protected getRegion(): string { - return this.options.region || this.serverless.service.provider.region; - } - protected getStage(): string { - return this.options.stage || this.serverless.service.provider.stage; - } - - protected getResourceGroupName(): string { - return this.options["resourceGroup"] - || this.serverless.service.provider["resourceGroup"] - || `${this.serviceName}-${this.getRegion()}-${this.getStage()}-rg`; + if (!this.serverless.service.provider["prefix"]) { + this.serverless.service.provider["prefix"] = "sls"; + } } } diff --git a/src/services/functionAppService.test.ts b/src/services/functionAppService.test.ts index fce61cb8..51aa5278 100644 --- a/src/services/functionAppService.test.ts +++ b/src/services/functionAppService.test.ts @@ -4,9 +4,12 @@ import mockFs from "mock-fs"; import Serverless from "serverless"; import { MockFactory } from "../test/mockFactory"; import { FunctionAppService } from "./functionAppService"; +import { ArmService } from "./armService"; +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") describe("Function App Service", () => { @@ -33,7 +36,7 @@ describe("Function App Service", () => { beforeAll(() => { - // TODO: How to spy on defaul exported function? + // TODO: How to spy on default exported function? const axiosMock = new MockAdapter(axios); // Master Key @@ -86,7 +89,7 @@ describe("Function App Service", () => { const service = createService(); const result = await service.get(); expect(WebSiteManagementClient.prototype.webApps.get) - .toBeCalledWith(provider.resourceGroup, slsService["service"]); + .toBeCalledWith(provider.resourceGroup, FunctionAppResource.getResourceName(slsService as any)); expect(result).toEqual(app) }); @@ -98,7 +101,7 @@ describe("Function App Service", () => { } as any; const result = await service.get(); expect(WebSiteManagementClient.prototype.webApps.get) - .toBeCalledWith(provider.resourceGroup, slsService["service"]); + .toBeCalledWith(provider.resourceGroup, FunctionAppResource.getResourceName(slsService as any)); expect(result).toBeNull(); }); @@ -140,6 +143,59 @@ describe("Function App Service", () => { expect(await service.listFunctions(app)).toEqual(functionsResponse.map((f) => f.properties)); }); + describe("Deployments", () => { + const expectedDeployment: ArmDeployment = { + parameters: {}, + template: MockFactory.createTestArmTemplate(), + }; + + const expectedSite = MockFactory.createTestSite(); + + beforeEach(() => { + FunctionAppService.prototype.get = jest.fn(() => Promise.resolve(expectedSite)); + ArmService.prototype.createDeploymentFromConfig = jest.fn(() => Promise.resolve(expectedDeployment)); + ArmService.prototype.createDeploymentFromType = jest.fn(() => Promise.resolve(expectedDeployment)); + ArmService.prototype.deployTemplate = jest.fn(() => Promise.resolve(null)); + }); + + it("deploys ARM templates with custom configuration", async () => { + slsService.provider["armTemplate"] = {}; + + const service = createService(); + const site = await service.deploy(); + + expect(site).toEqual(expectedSite); + expect(ArmService.prototype.createDeploymentFromConfig).toBeCalledWith(slsService.provider["armTemplate"]); + expect(ArmService.prototype.createDeploymentFromType).not.toBeCalled(); + expect(ArmService.prototype.deployTemplate).toBeCalledWith(expectedDeployment); + }); + + it("deploys ARM template from well-known (default) configuration", async () => { + slsService.provider["armTemplate"] = null; + + const service = createService(); + const site = await service.deploy(); + + expect(site).toEqual(expectedSite); + expect(ArmService.prototype.createDeploymentFromConfig).not.toBeCalled(); + expect(ArmService.prototype.createDeploymentFromType).toBeCalledWith(ArmTemplateType.Consumption); + expect(ArmService.prototype.deployTemplate).toBeCalledWith(expectedDeployment); + }); + + it("deploys ARM template from well-known configuration", async () => { + slsService.provider["armTemplate"] = null; + slsService.provider["type"] = "premium"; + + const service = createService(); + const site = await service.deploy(); + + expect(site).toEqual(expectedSite); + expect(ArmService.prototype.createDeploymentFromConfig).not.toBeCalled(); + expect(ArmService.prototype.createDeploymentFromType).toBeCalledWith(ArmTemplateType.Premium); + expect(ArmService.prototype.deployTemplate).toBeCalledWith(expectedDeployment); + }); + }); + it("uploads functions", async () => { const service = createService(); await service.uploadFunctions(app); diff --git a/src/services/functionAppService.ts b/src/services/functionAppService.ts index 4237f37a..b4d26f63 100644 --- a/src/services/functionAppService.ts +++ b/src/services/functionAppService.ts @@ -1,29 +1,25 @@ import fs from "fs"; import path from "path"; import { WebSiteManagementClient } from "@azure/arm-appservice"; -import { ResourceManagementClient } from "@azure/arm-resources"; -import { Deployment } from "@azure/arm-resources/esm/models"; -import jsonpath from "jsonpath"; -import _ from "lodash"; import Serverless from "serverless"; import { BaseService } from "./baseService"; import { FunctionAppHttpTriggerConfig } from "../models/functionApp"; import { Site, FunctionEnvelope } from "@azure/arm-appservice/esm/models"; import { Guard } from "../shared/guard"; +import { ArmService } from "./armService"; +import { ArmDeployment } from "../models/armTemplates"; +import { FunctionAppResource } from "../armTemplates/resources/functionApp"; export class FunctionAppService extends BaseService { - private resourceClient: ResourceManagementClient; private webClient: WebSiteManagementClient; public constructor(serverless: Serverless, options: Serverless.Options) { super(serverless, options); - - this.resourceClient = new ResourceManagementClient(this.credentials, this.subscriptionId); this.webClient = new WebSiteManagementClient(this.credentials, this.subscriptionId); } public async get(): Promise { - const response: any = await this.webClient.webApps.get(this.resourceGroup, this.serviceName); + const response: any = await this.webClient.webApps.get(this.resourceGroup, FunctionAppResource.getResourceName(this.config)); if (response.error && (response.error.code === "ResourceNotFound" || response.error.code === "ResourceGroupNotFound")) { return null; } @@ -111,7 +107,7 @@ export class FunctionAppService extends BaseService { } public async uploadFunctions(functionApp: Site): Promise { - Guard.null(functionApp); + Guard.null(functionApp, "functionApp"); this.log("Deploying serverless functions..."); await this.zipDeploy(functionApp); @@ -123,67 +119,13 @@ export class FunctionAppService extends BaseService { */ public async deploy() { this.log(`Creating function app: ${this.serviceName}`); - let parameters: any = { functionAppName: { value: this.serviceName } }; - - const gitUrl = this.serverless.service.provider["gitUrl"]; - - if (gitUrl) { - parameters = { - functionAppName: { value: this.serviceName }, - gitUrl: { value: gitUrl } - }; - } - - let templateFilePath = path.join(__dirname, "..", "provider", "armTemplates", "azuredeploy.json"); - - if (gitUrl) { - templateFilePath = path.join(__dirname, "armTemplates", "azuredeployWithGit.json"); - } - - if (this.serverless.service.provider["armTemplate"]) { - this.log(`-> Deploying custom ARM template: ${this.serverless.service.provider["armTemplate"].file}`); - templateFilePath = path.join(this.serverless.config.servicePath, this.serverless.service.provider["armTemplate"].file); - const userParameters = this.serverless.service.provider["armTemplate"].parameters; - const userParametersKeys = Object.keys(userParameters); - - for (let paramIndex = 0; paramIndex < userParametersKeys.length; paramIndex++) { - const item = {}; - item[userParametersKeys[paramIndex]] = { "value": userParameters[userParametersKeys[paramIndex]] }; - parameters = _.merge(parameters, item); - } - } - - let template = JSON.parse(fs.readFileSync(templateFilePath, "utf8")); - - // Check if there are custom environment variables defined that need to be - // added to the ARM template used in the deployment. - const environmentVariables = this.serverless.service.provider["environment"]; - if (environmentVariables) { - const appSettingsPath = "$.resources[?(@.kind==\"functionapp\")].properties.siteConfig.appSettings"; - - jsonpath.apply(template, appSettingsPath, function (appSettingsList) { - Object.keys(environmentVariables).forEach(function (key) { - appSettingsList.push({ - name: key, - value: environmentVariables[key] - }); - }); - - return appSettingsList; - }); - } - - const deploymentParameters: Deployment = { - properties: { - mode: "Incremental", - parameters, - template - } - }; + const armService = new ArmService(this.serverless); + let deployment: ArmDeployment = this.config.provider.armTemplate + ? await armService.createDeploymentFromConfig(this.config.provider.armTemplate) + : await armService.createDeploymentFromType(this.config.provider.type || "consumption"); - // Deploy ARM template - await this.resourceClient.deployments.createOrUpdate(this.resourceGroup, this.deploymentName, deploymentParameters); + await armService.deployTemplate(deployment); // Return function app return await this.get(); diff --git a/src/test/mockFactory.ts b/src/test/mockFactory.ts index 2a7ede24..d6fa7a80 100644 --- a/src/test/mockFactory.ts +++ b/src/test/mockFactory.ts @@ -12,8 +12,9 @@ import PluginManager from "serverless/lib/classes/PluginManager"; import { ServerlessAzureConfig } from "../models/serverless"; import { AzureServiceProvider, ServicePrincipalEnvVariables } from "../models/azureProvider" import { Logger } from "../models/generic"; -import { ApiCorsPolicy } from "../models/apiManagement"; +import { ApiCorsPolicy, ApiManagementConfig } from "../models/apiManagement"; import { DeploymentsListByResourceGroupResponse } from "@azure/arm-resources/esm/models"; +import { ArmResourceTemplate } from "../models/armTemplates"; function getAttribute(object: any, prop: string, defaultValue: any): any { if (object && object[prop]) { @@ -145,7 +146,7 @@ export class MockFactory { const result = []; for (let i = 0; i < count; i++) { result.push({ - name: `deployment${i+1}`, + name: `deployment${i + 1}`, properties: { timestamp: new Date(), } @@ -206,7 +207,21 @@ export class MockFactory { functions: functionMetadata || MockFactory.createTestSlsFunctionConfig(), } return (asYaml) ? yaml.dump(data) : data; - } + } + + public static createTestApimConfig(): ApiManagementConfig { + return { + name: "test-apim-resource", + api: { + name: "test-apim-api1", + subscriptionRequired: false, + displayName: "API 1", + description: "description of api 1", + protocols: ["https"], + path: "test-api1", + }, + }; + } public static createTestFunctionApimConfig(name: string) { return { @@ -432,7 +447,26 @@ export class MockFactory { allowedOrigins: ["*"], allowedHeaders: ["*"], exposeHeaders: ["*"], - allowedMethods: ["GET","POST"], + allowedMethods: ["GET", "POST"], + }; + } + + public static createTestArmTemplate(): ArmResourceTemplate { + return { + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "param1": { + "defaultValue": "", + "type": "String" + }, + "param2": { + "defaultValue": "", + "type": "String" + }, + }, + "variables": {}, + "resources": [] }; }