diff --git a/package-lock.json b/package-lock.json index ddb886eb..b52ea548 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2852,6 +2852,11 @@ "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", "dev": true }, + "charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc=" + }, "chokidar": { "version": "2.1.6", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-2.1.6.tgz", @@ -3301,6 +3306,11 @@ "which": "^1.2.9" } }, + "crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs=" + }, "crypto-browserify": { "version": "3.12.0", "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.0.tgz", @@ -7563,6 +7573,16 @@ "object-visit": "^1.0.0" } }, + "md5": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.2.1.tgz", + "integrity": "sha1-U6s41f48iJG6RlMp6iP6wFQBJvk=", + "requires": { + "charenc": "~0.0.1", + "crypt": "~0.0.1", + "is-buffer": "~1.1.1" + } + }, "md5.js": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", diff --git a/package.json b/package.json index 2d65b3f3..c0e9b793 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "js-yaml": "^3.13.1", "jsonpath": "^1.0.1", "lodash": "^4.16.6", + "md5": "^2.2.1", "open": "^6.3.0", "request": "^2.81.0", "rimraf": "^2.6.3", diff --git a/src/armTemplates/compositeArmTemplate.ts b/src/armTemplates/compositeArmTemplate.ts index a56b5474..73d834b9 100644 --- a/src/armTemplates/compositeArmTemplate.ts +++ b/src/armTemplates/compositeArmTemplate.ts @@ -1,6 +1,10 @@ -import { ArmResourceTemplateGenerator, ArmResourceTemplate } from "../models/armTemplates"; +import { + ArmResourceTemplateGenerator, + ArmResourceTemplate +} from "../models/armTemplates"; import { Guard } from "../shared/guard"; import { ServerlessAzureConfig } from "../models/serverless"; +import { Utils } from "../shared/utils"; export class CompositeArmTemplate implements ArmResourceTemplateGenerator { public constructor(private childTemplates: ArmResourceTemplateGenerator[]) { @@ -9,22 +13,23 @@ export class CompositeArmTemplate implements ArmResourceTemplateGenerator { 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": [], + $schema: + "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + contentVersion: "1.0.0.0", + parameters: {}, + resources: [] }; - this.childTemplates.forEach((resource) => { + this.childTemplates.forEach(resource => { const resourceTemplate = resource.getTemplate(); template.parameters = { ...template.parameters, - ...resourceTemplate.parameters, + ...resourceTemplate.parameters }; template.resources = [ ...template.resources, - ...resourceTemplate.resources, + ...resourceTemplate.resources ]; }); @@ -34,14 +39,14 @@ export class CompositeArmTemplate implements ArmResourceTemplateGenerator { public getParameters(config: ServerlessAzureConfig) { let parameters = {}; - this.childTemplates.forEach((resource) => { + this.childTemplates.forEach(resource => { parameters = { ...parameters, ...resource.getParameters(config), - location: config.provider.region, - } + location: Utils.getNormalizedRegionName(config.provider.region) + }; }); return parameters; } -} \ No newline at end of file +} diff --git a/src/armTemplates/resources/apim.ts b/src/armTemplates/resources/apim.ts index 46587dbf..eaca3277 100644 --- a/src/armTemplates/resources/apim.ts +++ b/src/armTemplates/resources/apim.ts @@ -1,12 +1,13 @@ import { ServerlessAzureConfig } from "../../models/serverless"; import { ArmResourceTemplateGenerator, ArmResourceTemplate } from "../../models/armTemplates"; import { ApiManagementConfig } from "../../models/apiManagement"; +import { Utils } from "../../shared/utils"; 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`; + : `${config.provider.prefix}-${Utils.createShortAzureRegionName(config.provider.region)}-${Utils.createShortStageName(config.provider.stage)}-apim`; } public getTemplate(): ArmResourceTemplate { diff --git a/src/armTemplates/resources/appInsights.ts b/src/armTemplates/resources/appInsights.ts index 1223062f..8667d923 100644 --- a/src/armTemplates/resources/appInsights.ts +++ b/src/armTemplates/resources/appInsights.ts @@ -1,11 +1,12 @@ import { ArmResourceTemplateGenerator, ArmResourceTemplate } from "../../models/armTemplates"; import { ServerlessAzureConfig } from "../../models/serverless"; +import { Utils } from "../../shared/utils"; 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`; + : `${config.provider.prefix}-${Utils.createShortAzureRegionName(config.provider.region)}-${Utils.createShortStageName(config.provider.stage)}-appinsights`; } public getTemplate(): ArmResourceTemplate { diff --git a/src/armTemplates/resources/appServicePlan.ts b/src/armTemplates/resources/appServicePlan.ts index 8a6a378f..95819962 100644 --- a/src/armTemplates/resources/appServicePlan.ts +++ b/src/armTemplates/resources/appServicePlan.ts @@ -1,11 +1,12 @@ import { ArmResourceTemplateGenerator, ArmResourceTemplate } from "../../models/armTemplates"; import { ServerlessAzureConfig, ResourceConfig } from "../../models/serverless"; +import { Utils } from "../../shared/utils"; 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`; + : `${config.provider.prefix}-${Utils.createShortAzureRegionName(config.provider.region)}-${Utils.createShortStageName(config.provider.stage)}-asp`; } public getTemplate(): ArmResourceTemplate { diff --git a/src/armTemplates/resources/functionApp.ts b/src/armTemplates/resources/functionApp.ts index 50caba93..d2058a38 100644 --- a/src/armTemplates/resources/functionApp.ts +++ b/src/armTemplates/resources/functionApp.ts @@ -1,11 +1,14 @@ import { ArmResourceTemplateGenerator, ArmResourceTemplate } from "../../models/armTemplates"; import { ServerlessAzureConfig, FunctionAppConfig } from "../../models/serverless"; +import { Utils } from "../../shared/utils"; export class FunctionAppResource implements ArmResourceTemplateGenerator { public static getResourceName(config: ServerlessAzureConfig) { + const safeServiceName = config.service.replace(/\s/g, "-"); + return config.provider.functionApp && config.provider.functionApp.name ? config.provider.functionApp.name - : `${config.provider.prefix}-${config.provider.region}-${config.provider.stage}-${config.service}`; + : `${config.provider.prefix}-${Utils.createShortAzureRegionName(config.provider.region)}-${Utils.createShortStageName(config.provider.stage)}-${safeServiceName}`; } public getTemplate(): ArmResourceTemplate { diff --git a/src/armTemplates/resources/hostingEnvironment.ts b/src/armTemplates/resources/hostingEnvironment.ts index 0591002e..233aff04 100644 --- a/src/armTemplates/resources/hostingEnvironment.ts +++ b/src/armTemplates/resources/hostingEnvironment.ts @@ -1,11 +1,12 @@ import { ArmResourceTemplateGenerator, ArmResourceTemplate } from "../../models/armTemplates"; import { ServerlessAzureConfig } from "../../models/serverless"; +import { Utils } from "../../shared/utils"; 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`; + : `${config.provider.prefix}-${Utils.createShortAzureRegionName(config.provider.region)}-${Utils.createShortStageName(config.provider.stage)}-ase`; } public getTemplate(): ArmResourceTemplate { diff --git a/src/armTemplates/resources/storageAccount.test.ts b/src/armTemplates/resources/storageAccount.test.ts new file mode 100644 index 00000000..6507027b --- /dev/null +++ b/src/armTemplates/resources/storageAccount.test.ts @@ -0,0 +1,154 @@ +import md5 from "md5"; +import { StorageAccountResource } from "./storageAccount"; +import { ServerlessAzureConfig } from "../../models/serverless"; +import { Utils } from "../../shared/utils"; + +describe("Storage Account Resource", () => { + const config: ServerlessAzureConfig = { + functions: [], + plugins: [], + provider: { + prefix: "sls", + name: "azure", + region: "westus", + stage: "dev", + }, + service: "test-api" + } + + it("Generates safe storage account name with short parts", () => { + const testConfig: ServerlessAzureConfig = { + ...config, + service: "test-api", + }; + + const result = StorageAccountResource.getResourceName(testConfig); + assertValidStorageAccountName(testConfig, result); + expect(result.startsWith("slswusdev")).toBe(true); + }); + + it("Generates safe storage account names with long parts", () => { + const testConfig: ServerlessAzureConfig = { + ...config, + provider: { + ...config.provider, + prefix: "my-long-test-prefix-name", + region: "Australia Southeast", + stage: "development" + }, + service: "my-long-test-api", + }; + + const result = StorageAccountResource.getResourceName(testConfig); + assertValidStorageAccountName(testConfig, result); + expect(result.startsWith("mylaussedev")).toBe(true); + }); + + it("Generating a storage account name is idempotent", () => { + const result1 = StorageAccountResource.getResourceName(config); + const result2 = StorageAccountResource.getResourceName(config); + + expect(result1).toEqual(result2); + }); + + it("Generates distinct account names based on region", () => { + const regions = [ + "eastasia", + "southeastasia", + "centralus", + "eastus", + "eastus2", + "westus", + "northcentralus", + "southcentralus", + "northeurope", + "westeurope", + "japanwest", + "japaneast", + "brazilsouth", + "australiaeast", + "australiasoutheast", + "southindia", + "centralindia", + "westindia", + "canadacentral", + "canadaeast", + "uksouth", + "ukwest", + "westcentralus", + "westus2", + "koreacentral", + "koreasouth", + "francecentral", + "francesouth", + "australiacentral", + "australiacentral2", + "uaecentral", + "uaenorth", + "southafricanorth", + "southafricawest" + ]; + + const regionConfigs = regions.map((region) => { + return { + ...config, + provider: { + ...config.provider, + region: region, + } + }; + }); + + const results = {}; + regionConfigs.forEach((config) => { + const result = StorageAccountResource.getResourceName(config); + assertValidStorageAccountName(config, result); + results[result] = config; + }); + + expect(Object.keys(results)).toHaveLength(regionConfigs.length); + }); + + it("Generates distinct account names based on stage", () => { + const stages = [ + "dev", + "test", + "qa", + "uat", + "prod", + "preprod", + ]; + + const stageConfigs = stages.map((region) => { + return { + ...config, + provider: { + ...config.provider, + region: region, + } + }; + }); + + const results = {}; + stageConfigs.forEach((config) => { + const result = StorageAccountResource.getResourceName(config); + assertValidStorageAccountName(config, result); + results[result] = config; + }); + + expect(Object.keys(results)).toHaveLength(stageConfigs.length); + }); + + function assertValidStorageAccountName(config: ServerlessAzureConfig, value: string) { + expect(value.length).toBeLessThanOrEqual(24); + expect(value.match(/[a-z0-9]/g).length).toEqual(value.length); + expect(value).toContain(Utils.createShortAzureRegionName(config.provider.region)); + expect(value).toContain(createSafeString(config.provider.prefix)); + expect(value).toContain(createSafeString(config.provider.stage)); + expect(value).toContain(md5(config.service).substr(0, 3)); + } + + function createSafeString(value: string) { + return value.replace(/\W+/g, "").toLocaleLowerCase().substr(0, 3); + }; +}); \ No newline at end of file diff --git a/src/armTemplates/resources/storageAccount.ts b/src/armTemplates/resources/storageAccount.ts index 0c59f8bc..0b6c9d8c 100644 --- a/src/armTemplates/resources/storageAccount.ts +++ b/src/armTemplates/resources/storageAccount.ts @@ -1,6 +1,7 @@ import { ArmResourceTemplateGenerator, ArmResourceTemplate } from "../../models/armTemplates"; import { ServerlessAzureConfig, ResourceConfig } from "../../models/serverless"; import { Utils } from "../../shared/utils"; +import md5 from "md5"; export class StorageAccountResource implements ArmResourceTemplateGenerator { public static getResourceName(config: ServerlessAzureConfig) { @@ -67,19 +68,30 @@ export class StorageAccountResource implements ArmResourceTemplateGenerator { /** * Gets a default storage account name. * Storage account names can have at most 24 characters and can have only alpha-numerics - * Default naming convention: - * - * "(first 3 of prefix)(first 3 of region)(first 3 of stage)(first 12 of service)sa" - * (Maximum of 23 characters) * @param config Serverless Azure Config */ private static getDefaultStorageAccountName(config: ServerlessAzureConfig): string { - const prefix = Utils.appendSubstrings( - 3, - config.provider.prefix, - config.provider.region, - config.provider.stage, - ); - return `${prefix}${config.service.substr(0, 12)}sa`.replace("-", "").toLocaleLowerCase(); + const maxAccountNameLength = 24; + const nameHash = md5(config.service); + const replacer = /\W+/g; + + let safePrefix = config.provider.prefix.replace(replacer, ""); + const safeRegion = Utils.createShortAzureRegionName(config.provider.region); + let safeStage = Utils.createShortStageName(config.provider.stage); + let safeNameHash = nameHash.substr(0, 6); + + const remaining = maxAccountNameLength - (safePrefix.length + safeRegion.length + safeStage.length + safeNameHash.length); + + // Dynamically adjust the substring based on space needed + if (remaining < 0) { + const partLength = Math.floor(Math.abs(remaining) / 3); + safePrefix = safePrefix.substr(0, partLength); + safeStage = safeStage.substr(0, partLength); + safeNameHash = safeNameHash.substr(0, partLength); + } + + return [safePrefix, safeRegion, safeStage, safeNameHash] + .join("") + .toLocaleLowerCase(); } } \ No newline at end of file diff --git a/src/armTemplates/resources/virtualNetwork.ts b/src/armTemplates/resources/virtualNetwork.ts index 9c1feff0..a9438935 100644 --- a/src/armTemplates/resources/virtualNetwork.ts +++ b/src/armTemplates/resources/virtualNetwork.ts @@ -1,11 +1,12 @@ import { ArmResourceTemplateGenerator, ArmResourceTemplate } from "../../models/armTemplates"; import { ServerlessAzureConfig } from "../../models/serverless"; +import { Utils } from "../../shared/utils"; 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`; + : `${config.provider.prefix}-${Utils.createShortAzureRegionName(config.provider.region)}-${Utils.createShortStageName(config.provider.stage)}-vnet`; } public getTemplate(): ArmResourceTemplate { diff --git a/src/models/azureProvider.ts b/src/models/azureProvider.ts index 9b293ae6..08483f8e 100644 --- a/src/models/azureProvider.ts +++ b/src/models/azureProvider.ts @@ -18,8 +18,3 @@ export interface FunctionMetadata { handler: string; events: FunctionEvent[]; } - -export interface AzureServiceProvider { - resourceGroup: string; - deploymentName: string; -} diff --git a/src/models/serverless.ts b/src/models/serverless.ts index bdf0dba4..dc7f0b91 100644 --- a/src/models/serverless.ts +++ b/src/models/serverless.ts @@ -29,27 +29,30 @@ export interface DeploymentConfig { container?: string; } +export interface ServerlessAzureProvider { + type?: string; + prefix?: string; + region: string; + stage: string; + name: string; + environment?: { + [key: string]: any; + }; + deploymentName?: string; + resourceGroup?: string; + apim?: ApiManagementConfig; + functionApp?: FunctionAppConfig; + appInsights?: ResourceConfig; + appServicePlan?: ResourceConfig; + storageAccount?: ResourceConfig; + hostingEnvironment?: ResourceConfig; + virtualNetwork?: ResourceConfig; + armTemplate?: ArmTemplateConfig; +} + export interface ServerlessAzureConfig { service: string; - provider: { - type?: string; - prefix?: string; - region: string; - stage: string; - name: string; - environment?: { - [key: string]: any; - }; - resourceGroup?: string; - apim?: ApiManagementConfig; - functionApp?: FunctionAppConfig; - appInsights?: ResourceConfig; - appServicePlan?: ResourceConfig; - storageAccount?: ResourceConfig; - hostingEnvironment?: ResourceConfig; - virtualNetwork?: ResourceConfig; - armTemplate?: ArmTemplateConfig; - }; + provider: ServerlessAzureProvider; plugins: string[]; functions: any; } diff --git a/src/services/azureBlobStorageService.test.ts b/src/services/azureBlobStorageService.test.ts index d4d1a10a..7d37cb77 100644 --- a/src/services/azureBlobStorageService.test.ts +++ b/src/services/azureBlobStorageService.test.ts @@ -12,9 +12,9 @@ import { StorageAccounts, StorageManagementClientContext } from "@azure/arm-stor jest.mock("./loginService"); import { AzureLoginService } from "./loginService" +import { StorageAccountResource } from "../armTemplates/resources/storageAccount"; describe("Azure Blob Storage Service", () => { - const filePath = "deployments/deployment.zip"; const fileName = "deployment.zip"; const fileContents = "contents"; @@ -22,7 +22,7 @@ describe("Azure Blob Storage Service", () => { const containers = MockFactory.createTestAzureContainers(); const sls = MockFactory.createTestServerless(); - const accountName = "slswesdevservicenamesa"; + const accountName = StorageAccountResource.getResourceName(sls.service as any); const options = MockFactory.createTestServerlessOptions(); const blockBlobUrl = MockFactory.createTestBlockBlobUrl(containerName, filePath); diff --git a/src/services/baseService.test.ts b/src/services/baseService.test.ts index 06e2de23..1ecf667d 100644 --- a/src/services/baseService.test.ts +++ b/src/services/baseService.test.ts @@ -8,6 +8,7 @@ import request from "request"; import fs from "fs"; import { BaseService } from "./baseService"; import { ServerlessAzureOptions } from "../models/serverless"; +import { Utils } from "../shared/utils"; class MockService extends BaseService { public constructor(serverless: Serverless, options?: ServerlessAzureOptions) { @@ -118,10 +119,9 @@ describe("Base Service", () => { sls.service.provider["resourceGroup"] = null; const mockService = new MockService(sls); const actualResourceGroupName = mockService.getResourceGroupName(); - - const region = mockService.getRegion(); - const stage = mockService.getStage(); - const expectedResourceGroupName = `sls-${region}-${stage}-${sls.service["service"]}-rg`; + const expectedRegion = Utils.createShortAzureRegionName(mockService.getRegion()); + const expectedStage = Utils.createShortStageName(mockService.getStage()); + const expectedResourceGroupName = `sls-${expectedRegion}-${expectedStage}-${sls.service["service"]}-rg`; expect(actualResourceGroupName).toEqual(expectedResourceGroupName); }); diff --git a/src/services/baseService.ts b/src/services/baseService.ts index 1d53d74e..6b782d4a 100644 --- a/src/services/baseService.ts +++ b/src/services/baseService.ts @@ -8,22 +8,23 @@ import { configConstants } from "../config"; import { DeploymentConfig, ServerlessAzureConfig } from "../models/serverless"; import { Guard } from "../shared/guard"; import { TokenCredentialsBase } from "@azure/ms-rest-nodeauth"; +import { Utils } from "../shared/utils"; export abstract class BaseService { protected baseUrl: string; protected serviceName: string; - protected credentials: TokenCredentialsBase + protected credentials: TokenCredentialsBase; protected subscriptionId: string; protected resourceGroup: string; protected deploymentName: string; - protected deploymentConfig: DeploymentConfig + protected deploymentConfig: DeploymentConfig; protected storageAccountName: string; protected config: ServerlessAzureConfig; protected constructor( protected serverless: Serverless, protected options: ServerlessAzureOptions = { stage: null, region: null }, - authenticate: boolean = true, + authenticate: boolean = true ) { Guard.null(serverless); this.setDefaultValues(); @@ -36,10 +37,14 @@ export abstract class BaseService { this.resourceGroup = this.getResourceGroupName(); this.deploymentConfig = this.getDeploymentConfig(); this.deploymentName = this.getDeploymentName(); - this.storageAccountName = StorageAccountResource.getResourceName(serverless.service as any) + this.storageAccountName = StorageAccountResource.getResourceName( + serverless.service as any + ); if (!this.credentials && authenticate) { - throw new Error(`Azure Credentials has not been set in ${this.constructor.name}`); + throw new Error( + `Azure Credentials has not been set in ${this.constructor.name}` + ); } } @@ -56,24 +61,25 @@ export abstract class BaseService { } public getResourceGroupName(): string { - const name = this.options.resourceGroup - || this.config.provider["resourceGroup"] - || `${this.getPrefix()}-${this.getRegion()}-${this.getStage()}-${this.serviceName}-rg`; + const regionName = Utils.createShortAzureRegionName(this.getRegion()); + const stageName = Utils.createShortStageName(this.getStage()); - return name; + return this.options.resourceGroup + || this.config.provider.resourceGroup + || `${this.getPrefix()}-${regionName}-${stageName}-${this.serviceName}-rg`; } public getDeploymentConfig(): DeploymentConfig { const providedConfig = this.serverless["deploy"] as DeploymentConfig; const config = providedConfig || { rollback: configConstants.rollbackEnabled, - container: configConstants.deploymentArtifactContainer, - } + container: configConstants.deploymentArtifactContainer + }; return config; } public getDeploymentName(): string { - const name = this.config.provider["deploymentName"] || `${this.resourceGroup}-deployment` + const name = this.config.provider.deploymentName || `${this.resourceGroup}-deployment`; return this.rollbackConfiguredName(name); } @@ -94,20 +100,24 @@ export abstract class BaseService { * @param relativeUrl The relative url * @param options Additional HTTP options including headers, etc */ - protected async sendApiRequest(method: string, relativeUrl: string, options: any = {}) { + protected async sendApiRequest( + method: string, + relativeUrl: string, + options: any = {} + ) { const defaultHeaders = { - Authorization: `Bearer ${this.getAccessToken()}`, + Authorization: `Bearer ${this.getAccessToken()}` }; const allHeaders = { ...defaultHeaders, - ...options.headers, + ...options.headers }; const requestOptions = { ...options, method, - headers: allHeaders, + headers: allHeaders }; return await axios(relativeUrl, requestOptions); @@ -120,14 +130,15 @@ export abstract class BaseService { */ protected sendFile(requestOptions, filePath) { return new Promise((resolve, reject) => { - fs.createReadStream(filePath) - .pipe(request(requestOptions, (err, response) => { + fs.createReadStream(filePath).pipe( + request(requestOptions, (err, response) => { if (err) { this.log(JSON.stringify(err, null, 4)); return reject(err); } resolve(response); - })); + }) + ); }); } @@ -140,16 +151,17 @@ export abstract class BaseService { } protected slsConfigFile(): string { - return ("config" in this.options) ? this.options["config"] : "serverless.yml"; + return "config" in this.options ? this.options["config"] : "serverless.yml"; } private setDefaultValues(): void { // TODO: Right now the serverless core will always default to AWS default region if the // region has not been set in the serverless.yml or CLI options - const awsDefault = "us-east-1" + const awsDefault = "us-east-1"; const providerRegion = this.serverless.service.provider.region; - if (!providerRegion || providerRegion === awsDefault) { // no region specified in serverless.yml + if (!providerRegion || providerRegion === awsDefault) { + // no region specified in serverless.yml this.serverless.service.provider.region = "westus"; } @@ -167,7 +179,9 @@ export abstract class BaseService { * @param name Original name */ private rollbackConfiguredName(name: string) { - return (this.deploymentConfig.rollback) ? `${name}-t${this.getTimestamp()}` : name; + return this.deploymentConfig.rollback + ? `${name}-t${this.getTimestamp()}` + : name; } /** diff --git a/src/services/resourceService.test.ts b/src/services/resourceService.test.ts index f3743067..3b757547 100644 --- a/src/services/resourceService.test.ts +++ b/src/services/resourceService.test.ts @@ -4,6 +4,7 @@ import { ResourceService } from "./resourceService"; jest.mock("@azure/arm-resources") import { ResourceManagementClient } from "@azure/arm-resources"; +import { Utils } from "../shared/utils"; describe("Resource Service", () => { const deployments = MockFactory.createTestDeployments(); @@ -37,14 +38,16 @@ describe("Resource Service", () => { const sls = MockFactory.createTestServerless(); const resourceGroup = "myResourceGroup" const location = "West Us"; + const expectedLocation = Utils.getNormalizedRegionName(location); sls.service.provider["resourceGroup"] = resourceGroup sls.service.provider.region = location; sls.variables["azureCredentials"] = "fake credentials" const options = MockFactory.createTestServerlessOptions(); const service = new ResourceService(sls, options); service.deployResourceGroup(); + expect(ResourceManagementClient.prototype.resourceGroups.createOrUpdate) - .toBeCalledWith(resourceGroup, { location }); + .toBeCalledWith(resourceGroup, { location: expectedLocation }); }); it("deletes a deployment", () => { diff --git a/src/services/resourceService.ts b/src/services/resourceService.ts index e36dd909..2a9baa29 100644 --- a/src/services/resourceService.ts +++ b/src/services/resourceService.ts @@ -1,6 +1,7 @@ import Serverless from "serverless"; import { ResourceManagementClient } from "@azure/arm-resources"; import { BaseService } from "./baseService"; +import { Utils } from "../shared/utils"; export class ResourceService extends BaseService { private resourceClient: ResourceManagementClient; @@ -20,7 +21,7 @@ export class ResourceService extends BaseService { this.log(`Creating resource group: ${this.resourceGroup}`); return await this.resourceClient.resourceGroups.createOrUpdate(this.resourceGroup, { - location: this.getRegion(), + location: Utils.getNormalizedRegionName(this.getRegion()), }); } diff --git a/src/shared/utils.test.ts b/src/shared/utils.test.ts index 8f319178..6982004e 100644 --- a/src/shared/utils.test.ts +++ b/src/shared/utils.test.ts @@ -78,4 +78,88 @@ describe("utils", () => { ) ).toEqual("abfgklpquvab"); }); + + it("Creates a short name for an azure region", () => { + const expected = "ausse"; + const actual = Utils.createShortAzureRegionName("australiasoutheast"); + + expect(actual).toEqual(expected); + }); + + it("Creates a short stage name from a well known name", () => { + const expected = "prod"; + const actual = Utils.createShortStageName("production"); + + expect(actual).toEqual(expected); + }); + + it("Creates a short stage name from a unknown name", () => { + const value = "user acceptance"; + const actual = Utils.createShortStageName(value); + + expect(actual).toEqual(value.substr(0, 3)); + }); + + it("Creates a short stage name from multiple values", () => { + const actual = Utils.createShortStageName("production dogfood"); + expect(actual).toEqual("proddf"); + }); + + it("Creates unique short names for all azure regions", () => { + const regions = [ + "eastasia", + "southeastasia", + "centralus", + "eastus", + "eastus2", + "westus", + "northcentralus", + "southcentralus", + "northeurope", + "westeurope", + "japanwest", + "japaneast", + "brazilsouth", + "australiaeast", + "australiasoutheast", + "southindia", + "centralindia", + "westindia", + "canadacentral", + "canadaeast", + "uksouth", + "ukwest", + "westcentralus", + "westus2", + "koreacentral", + "koreasouth", + "francecentral", + "francesouth", + "australiacentral", + "australiacentral2", + "uaecentral", + "uaenorth", + "southafricanorth", + "southafricawest" + ]; + + const results = {}; + regions.forEach((region) => { + const result = Utils.createShortAzureRegionName(region); + results[result] = region; + }); + + expect(Object.keys(results)).toHaveLength(regions.length); + }); + + it("gets a normalized region name from full region name", () => { + const result = Utils.getNormalizedRegionName("West US 2"); + expect(result).toEqual("westus2"); + }); + + it("Performs noop if region name is already normalized", () => { + const expected = "westus2"; + const actual = Utils.getNormalizedRegionName(expected); + expect(actual).toEqual(expected); + }); }); diff --git a/src/shared/utils.ts b/src/shared/utils.ts index 8777c344..974d865c 100644 --- a/src/shared/utils.ts +++ b/src/shared/utils.ts @@ -2,6 +2,7 @@ import { relative } from "path"; import Serverless from "serverless"; import { BindingUtils } from "./bindings"; import { constants } from "./constants"; +import { Guard } from "./guard"; export interface FunctionMetadata { entryPoint: any; @@ -104,7 +105,7 @@ export class Utils { entryPoint = handlerSplit[handlerSplit.length - 1]; handlerPath = `${handler.substring(0, handler.lastIndexOf("."))}.js`; } - + const metaData = { entryPoint: entryPoint, handlerPath: handlerPath @@ -136,4 +137,70 @@ export class Utils { } return result; } + + /** + * Creates a short name to be used for state name abbreviation + * @param stageName The stage name + */ + public static createShortStageName(stageName: string) { + Guard.empty(stageName); + + const stageMap = { + "dogfood": "df", + "production": "prod", + "development": "dev", + "testing": "test" + }; + + return this.createShortName(stageName, stageMap); + } + + /** + * Gets the normalized region name from long name (ex. West US 2 -> westus2) + * @param regionName The region name + */ + public static getNormalizedRegionName(regionName: string) { + Guard.empty(regionName); + return regionName.replace(/\W/g, "").toLowerCase(); + } + + /** + * Creates a short name for an azure region + * @param regionName The azure region name + */ + public static createShortAzureRegionName(regionName: string) { + Guard.empty(regionName); + + const locationMap = { + "north": "n", + "south": "s", + "east": "e", + "west": "w", + "central": "c", + }; + + return this.createShortName(regionName, locationMap); + } + + /** + * Creates a short name from a long name based on a well-known string map + * @param longName The long name to replace + * @param wellKnownMap A well known map of long terms to short abbreviations + */ + private static createShortName(longName: string, wellKnownMap: { [key: string]: string }) { + Guard.empty(longName); + Guard.null(wellKnownMap); + + const pattern = `(${Object.keys(wellKnownMap).join("|")})`; + const regex = new RegExp(pattern, "g"); + + return longName + .replace(/\W+/g, "") + .toLowerCase() + .split(regex) + .map((part) => { + return wellKnownMap[part] || part.substr(0, 3); + }) + .join(""); + } } diff --git a/src/test/mockFactory.ts b/src/test/mockFactory.ts index 6177bd7e..ba015c60 100644 --- a/src/test/mockFactory.ts +++ b/src/test/mockFactory.ts @@ -13,9 +13,9 @@ import Utils from "serverless/classes/Utils"; import PluginManager from "serverless/lib/classes/PluginManager"; import { ApiCorsPolicy, ApiManagementConfig } from "../models/apiManagement"; import { ArmResourceTemplate } from "../models/armTemplates"; -import { AzureServiceProvider, ServicePrincipalEnvVariables } from "../models/azureProvider"; +import { ServicePrincipalEnvVariables } from "../models/azureProvider"; import { Logger } from "../models/generic"; -import { ServerlessAzureConfig } from "../models/serverless"; +import { ServerlessAzureConfig, ServerlessAzureProvider } from "../models/serverless"; function getAttribute(object: any, prop: string, defaultValue: any): any { if (object && object[prop]) { @@ -305,10 +305,14 @@ export class MockFactory { return result; } - public static createTestAzureServiceProvider(): AzureServiceProvider { + public static createTestAzureServiceProvider(): ServerlessAzureProvider { return { + name: "azure", + prefix: "sls", resourceGroup: "myResourceGroup", deploymentName: "myDeploymentName", + region: "eastus2", + stage: "dev", } }