diff --git a/src/models/APISupport.ts b/src/models/APISupport.ts index dc16cc79..cd9386a4 100644 --- a/src/models/APISupport.ts +++ b/src/models/APISupport.ts @@ -5,7 +5,7 @@ import { TestKind } from "./engine/TestKind"; import * as Util from './util'; import * as FileUtils from './FileUtils'; import * as core from '@actions/core'; -import { FileInfo, TestModel, ExistingParams, TestRunModel } from "./PayloadModels"; +import { FileInfo, TestModel, ExistingParams, TestRunModel, AppComponents, ServerMetricConfig } from "./PayloadModels"; import { YamlConfig } from "./TaskModels"; import * as FetchUtil from './FetchHelper'; @@ -13,10 +13,8 @@ export class APISupport { authContext : AuthenticationUtils; yamlModel: YamlConfig; baseURL = ''; - existingParams: ExistingParams = {secrets: {}, env: {}, passFailCriteria: {}}; + existingParams: ExistingParams = {secrets: {}, env: {}, passFailCriteria: {}, appComponents: new Map()}; testId: string; - resourceName: string | undefined; - subName: string | undefined; constructor(authContext: AuthenticationUtils, yamlModel: YamlConfig) { this.authContext = authContext; @@ -32,8 +30,6 @@ export class APISupport { let header = await this.authContext.armTokenHeader(); let response = await FetchUtil.httpClientRetries(armEndpoint.toString(),header,'get',3,""); let resource_name: string | undefined = core.getInput('loadTestResource'); - this.resourceName = resource_name; - this.subName = this.authContext.subscriptionId; if(response.message.statusCode == 404) { var message = `The Azure Load Testing resource ${resource_name} does not exist. Please provide an existing resource.`; throw new Error(message); @@ -48,7 +44,7 @@ export class APISupport { } async getTestAPI(validate:boolean, returnTestObj:boolean = false) : Promise<[string | undefined, TestModel] | string | undefined> { - var urlSuffix = "tests/"+this.testId+"?api-version="+ ApiVersionConstants.tm2024Version; + var urlSuffix = "tests/"+this.testId+"?api-version="+ ApiVersionConstants.latestVersion; urlSuffix = this.baseURL+urlSuffix; let header = await this.authContext.getDataPlaneHeader(CallTypeForDP.get); let testResult = await FetchUtil.httpClientRetries(urlSuffix,header,'get',3,""); @@ -62,8 +58,8 @@ export class APISupport { if(testResult.message.statusCode != 200 && testResult.message.statusCode != 201){ if(validate){ // validate is called, then get should not be false, and this validate had retries because of the conflicts in jmx test, so lets not print in the console, instead put this in the error itself. - let testObj:any=await Util.getResultObj(testResult); - let err = testObj?.error?.message ? testObj?.error?.message : Util.ErrorCorrection(testResult); + let errorObj:any=await Util.getResultObj(testResult); + let err = errorObj?.error?.message ? errorObj?.error?.message : Util.ErrorCorrection(testResult); throw new Error(err); } else if(!validate && testResult.message.statusCode != 404){ // if not validate, then its to check if it is edit or create thats all, so it should not throw the error for 404. let testObj:any=await Util.getResultObj(testResult); @@ -101,28 +97,59 @@ export class APISupport { } } } + + async getAppComponents() { + let urlSuffix = "tests/"+this.testId+"/app-components/"+"?api-version="+ ApiVersionConstants.latestVersion; + urlSuffix = this.baseURL+urlSuffix; + let header = await this.authContext.getDataPlaneHeader(CallTypeForDP.get); + let appComponentsResult = await FetchUtil.httpClientRetries(urlSuffix,header,'get',3,""); + if(appComponentsResult.message.statusCode == 200) { + let appComponentsObj:AppComponents = await Util.getResultObj(appComponentsResult); + for(let guid in appComponentsObj.components){ + let resourceId = appComponentsObj.components[guid]?.resourceId ?? ""; + if(this.existingParams.appComponents.has(resourceId?.toLowerCase())) { + let existingGuids = this.existingParams.appComponents.get(resourceId?.toLowerCase()) ?? []; + existingGuids.push(guid); + this.existingParams.appComponents.set(resourceId.toLowerCase(), existingGuids); + } else { + this.existingParams.appComponents.set(resourceId.toLowerCase(), [guid]); + } + } + } + } + + async getServerMetricsConfig() { + let urlSuffix = "tests/"+this.testId+"/server-metrics-config/"+"?api-version="+ ApiVersionConstants.latestVersion; + urlSuffix = this.baseURL+urlSuffix; + let header = await this.authContext.getDataPlaneHeader(CallTypeForDP.get); + let serverComponentsResult = await FetchUtil.httpClientRetries(urlSuffix,header,'get',3,""); + if(serverComponentsResult.message.statusCode == 200) { + let serverComponentsObj: ServerMetricConfig = await Util.getResultObj(serverComponentsResult); + this.yamlModel.mergeExistingServerCriteria(serverComponentsObj); + } + } async deleteFileAPI(filename:string) { - var urlSuffix = "tests/"+this.testId+"/files/"+filename+"?api-version="+ ApiVersionConstants.tm2024Version; + var urlSuffix = "tests/"+this.testId+"/files/"+filename+"?api-version="+ ApiVersionConstants.latestVersion; urlSuffix = this.baseURL+urlSuffix; let header = await this.authContext.getDataPlaneHeader(CallTypeForDP.delete); let delFileResult = await FetchUtil.httpClientRetries(urlSuffix,header,'del',3,""); if(delFileResult.message.statusCode != 204) { - let delFileObj:any=await Util.getResultObj(delFileResult); - let Message: string = delFileObj ? delFileObj.message : Util.ErrorCorrection(delFileResult); + let errorObj:any=await Util.getResultObj(delFileResult); + let Message: string = errorObj ? errorObj.message : Util.ErrorCorrection(delFileResult); throw new Error(Message); } } async createTestAPI() { - let urlSuffix = "tests/"+this.testId+"?api-version="+ ApiVersionConstants.tm2024Version; + let urlSuffix = "tests/"+this.testId+"?api-version="+ ApiVersionConstants.latestVersion; urlSuffix = this.baseURL+urlSuffix; let createData = this.yamlModel.getCreateTestData(this.existingParams); let header = await this.authContext.getDataPlaneHeader(CallTypeForDP.patch); let createTestresult = await FetchUtil.httpClientRetries(urlSuffix,header,'patch',3,JSON.stringify(createData)); if(createTestresult.message.statusCode != 200 && createTestresult.message.statusCode != 201) { - let testRunObj:any=await Util.getResultObj(createTestresult); - console.log(testRunObj ? testRunObj : Util.ErrorCorrection(createTestresult)); + let errorObj:any=await Util.getResultObj(createTestresult); + console.log(errorObj ? errorObj : Util.ErrorCorrection(createTestresult)); throw new Error("Error in creating test: " + this.testId); } if(createTestresult.message.statusCode == 201) { @@ -174,68 +201,114 @@ export class APISupport { } await this.uploadConfigFile(); } - + + async patchAppComponents() { + let urlSuffix = "tests/"+this.testId+"/app-components/"+"?api-version="+ ApiVersionConstants.latestVersion; + urlSuffix = this.baseURL+urlSuffix; + let appComponentsData : AppComponents = this.yamlModel.getAppComponentsData(); + let header = await this.authContext.getDataPlaneHeader(CallTypeForDP.patch); + let appComponentsResult = await FetchUtil.httpClientRetries(urlSuffix,header,'patch',3,JSON.stringify(appComponentsData)); + if(appComponentsResult.message.statusCode != 200 && appComponentsResult.message.statusCode != 201) { + let errorObj:any=await Util.getResultObj(appComponentsResult); + console.log(errorObj ? errorObj : Util.ErrorCorrection(appComponentsResult)); + throw new Error("Error in updating app components"); + } else { + console.log("Updated app components successfully"); + let appComponentsObj:AppComponents = await Util.getResultObj(appComponentsResult); + for(let guid in appComponentsObj.components){ + let resourceId = appComponentsObj.components[guid]?.resourceId ?? ""; + if(this.existingParams.appComponents.has(resourceId?.toLowerCase())) { + let existingGuids = this.existingParams.appComponents.get(resourceId?.toLowerCase()) ?? []; + existingGuids.push(guid); + this.existingParams.appComponents.set(resourceId.toLowerCase(), existingGuids); + } else { + this.existingParams.appComponents.set(resourceId.toLowerCase(), [guid]); + } + } + await this.getServerMetricsConfig(); + await this.patchServerMetrics(); + } + } + + async patchServerMetrics() { + let urlSuffix = "tests/"+this.testId+"/server-metrics-config/"+"?api-version="+ ApiVersionConstants.latestVersion; + urlSuffix = this.baseURL+urlSuffix; + let serverMetricsData : ServerMetricConfig = { + metrics: this.yamlModel.serverMetricsConfig + } + let header = await this.authContext.getDataPlaneHeader(CallTypeForDP.patch); + let serverMetricsResult = await FetchUtil.httpClientRetries(urlSuffix,header,'patch',3,JSON.stringify(serverMetricsData)); + if(serverMetricsResult.message.statusCode != 200 && serverMetricsResult.message.statusCode != 201) { + let errorObj:any=await Util.getResultObj(serverMetricsResult); + console.log(errorObj ? errorObj : Util.ErrorCorrection(serverMetricsResult)); + throw new Error("Error in updating server metrics"); + } else { + console.log("Updated server metrics successfully"); + } + } + async uploadTestPlan() { - let retry = 5; - let filepath = this.yamlModel.testPlan; - let filename = this.yamlModel.getFileName(filepath); - let urlSuffix = "tests/"+this.testId+"/files/"+filename+"?api-version="+ ApiVersionConstants.tm2024Version; - - let fileType = FileType.TEST_SCRIPT; - if(this.yamlModel.kind == TestKind.URL){ - fileType = FileType.URL_TEST_CONFIG; - } - urlSuffix = this.baseURL + urlSuffix + ("&fileType=" + fileType); - - let headers = await this.authContext.getDataPlaneHeader(CallTypeForDP.put) - let uploadresult = await FetchUtil.httpClientRetries(urlSuffix,headers,'put',3,filepath, true); - if(uploadresult.message.statusCode != 201){ - let uploadObj:any = await Util.getResultObj(uploadresult); - console.log(uploadObj ? uploadObj : Util.ErrorCorrection(uploadresult)); - throw new Error("Error in uploading TestPlan for the created test"); - } - else { - console.log("Uploaded test plan for the test"); - let minutesToAdd=10; - let startTime = new Date(); - let maxAllowedTime = new Date(startTime.getTime() + minutesToAdd*60000); - let validationStatus : string | undefined = "VALIDATION_INITIATED"; - let testObj: TestModel | null = null; - while(maxAllowedTime>(new Date()) && (validationStatus == "VALIDATION_INITIATED" || validationStatus == "NOT_VALIDATED" || validationStatus == null)) { - try{ - [validationStatus, testObj] = await this.getTestAPI(true, true) as [string | undefined, TestModel]; - } - catch(e) { - retry--; - if(retry == 0){ - throw new Error("Unable to validate the test plan. Please retry. Failed with error :" + e); - } + let retry = 5; + let filepath = this.yamlModel.testPlan; + let filename = this.yamlModel.getFileName(filepath); + let urlSuffix = "tests/"+this.testId+"/files/"+filename+"?api-version="+ ApiVersionConstants.latestVersion; + + let fileType = FileType.TEST_SCRIPT; + if(this.yamlModel.kind == TestKind.URL){ + fileType = FileType.URL_TEST_CONFIG; + } + urlSuffix = this.baseURL + urlSuffix + ("&fileType=" + fileType); + + let headers = await this.authContext.getDataPlaneHeader(CallTypeForDP.put) + let uploadresult = await FetchUtil.httpClientRetries(urlSuffix,headers,'put',3,filepath, true); + if(uploadresult.message.statusCode != 201){ + let errorObj:any = await Util.getResultObj(uploadresult); + console.log(errorObj ? errorObj : Util.ErrorCorrection(uploadresult)); + throw new Error("Error in uploading TestPlan for the created test"); + } + else { + console.log("Uploaded test plan for the test"); + let minutesToAdd=10; + let startTime = new Date(); + let maxAllowedTime = new Date(startTime.getTime() + minutesToAdd*60000); + let validationStatus : string | undefined = "VALIDATION_INITIATED"; + let testObj: TestModel | null = null; + while(maxAllowedTime>(new Date()) && (validationStatus == "VALIDATION_INITIATED" || validationStatus == "NOT_VALIDATED" || validationStatus == null)) { + try{ + [validationStatus, testObj] = await this.getTestAPI(true, true) as [string | undefined, TestModel]; + } + catch(e) { + retry--; + if(retry == 0){ + throw new Error("Unable to validate the test plan. Please retry. Failed with error :" + e); } - await Util.sleep(5000); } - console.log("Validation status of the test plan: "+ validationStatus); - if(validationStatus == null || validationStatus == "VALIDATION_SUCCESS" ){ - console.log(`Validated test plan for the test successfully.`); - - // Get errors from all files - let fileErrors = Util.getAllFileErrors(testObj); - - if (Object.keys(fileErrors).length > 0) { - console.log("Validation failed for the following files:"); - for (const [file, error] of Object.entries(fileErrors)) { - console.log(`File: ${file}, Error: ${error}`); - } - throw new Error("Validation of one or more files failed. Please correct the errors and try again."); + await Util.sleep(5000); + } + await this.patchAppComponents(); + console.log("Validation status of the test plan: "+ validationStatus); + if(validationStatus == null || validationStatus == "VALIDATION_SUCCESS" ){ + console.log(`Validated test plan for the test successfully.`); + + // Get errors from all files + let fileErrors = Util.getAllFileErrors(testObj); + + if (Object.keys(fileErrors).length > 0) { + console.log("Validation failed for the following files:"); + for (const [file, error] of Object.entries(fileErrors)) { + console.log(`File: ${file}, Error: ${error}`); } - - await this.createTestRun(); + throw new Error("Validation of one or more files failed. Please correct the errors and try again."); } - else if(validationStatus == "VALIDATION_INITIATED" || validationStatus == "NOT_VALIDATED") - throw new Error("TestPlan validation timeout. Please try again.") - else - throw new Error("TestPlan validation Failed."); + + await this.createTestRun(); } + else if(validationStatus == "VALIDATION_INITIATED" || validationStatus == "NOT_VALIDATED") + throw new Error("TestPlan validation timeout. Please try again.") + else + throw new Error("TestPlan validation Failed."); + } } async uploadConfigFile() @@ -244,13 +317,13 @@ export class APISupport { if(configFiles != undefined && configFiles.length > 0) { for(let filepath of configFiles){ let filename = this.yamlModel.getFileName(filepath); - let urlSuffix = "tests/"+ this.testId +"/files/"+filename+"?api-version="+ ApiVersionConstants.tm2024Version + ("&fileType=" + FileType.ADDITIONAL_ARTIFACTS); + let urlSuffix = "tests/"+ this.testId +"/files/"+filename+"?api-version="+ ApiVersionConstants.latestVersion + ("&fileType=" + FileType.ADDITIONAL_ARTIFACTS); urlSuffix = this.baseURL+urlSuffix; let headers = await this.authContext.getDataPlaneHeader(CallTypeForDP.put); let uploadresult = await FetchUtil.httpClientRetries(urlSuffix,headers,'put',3,filepath, true); if(uploadresult.message.statusCode != 201){ - let uploadObj:any = await Util.getResultObj(uploadresult); - console.log(uploadObj ? uploadObj : Util.ErrorCorrection(uploadresult)); + let errorObj:any = await Util.getResultObj(uploadresult); + console.log(errorObj ? errorObj : Util.ErrorCorrection(uploadresult)); throw new Error("Error in uploading config file for the created test"); } }; @@ -266,13 +339,13 @@ export class APISupport { console.log("Uploading and validating the zip artifacts"); for(const filepath of zipFiles){ let filename = this.yamlModel.getFileName(filepath); - var urlSuffix = "tests/"+this.testId+"/files/"+filename+"?api-version=" + ApiVersionConstants.tm2024Version+"&fileType="+FileType.ZIPPED_ARTIFACTS; + var urlSuffix = "tests/"+this.testId+"/files/"+filename+"?api-version=" + ApiVersionConstants.latestVersion+"&fileType="+FileType.ZIPPED_ARTIFACTS; urlSuffix = this.baseURL+urlSuffix; let headers = await this.authContext.getDataPlaneHeader(CallTypeForDP.put); let uploadresult = await FetchUtil.httpClientRetries(urlSuffix,headers,'put',3,filepath, true); if(uploadresult.message.statusCode != 201){ - let uploadObj:any = await Util.getResultObj(uploadresult); - console.log(uploadObj ? uploadObj : Util.ErrorCorrection(uploadresult)); + let errorObj:any = await Util.getResultObj(uploadresult); + console.log(errorObj ? errorObj : Util.ErrorCorrection(uploadresult)); throw new Error("Error in uploading config file for the created test"); } } @@ -288,13 +361,13 @@ export class APISupport { let propertyFile = this.yamlModel.propertyFile; if(propertyFile != undefined && propertyFile!= '') { let filename = this.yamlModel.getFileName(propertyFile); - let urlSuffix = "tests/"+this.testId+"/files/"+filename+"?api-version="+ ApiVersionConstants.tm2024Version+"&fileType="+FileType.USER_PROPERTIES; + let urlSuffix = "tests/"+this.testId+"/files/"+filename+"?api-version="+ ApiVersionConstants.latestVersion+"&fileType="+FileType.USER_PROPERTIES; urlSuffix = this.baseURL + urlSuffix; let headers = await this.authContext.getDataPlaneHeader(CallTypeForDP.put); let uploadresult = await FetchUtil.httpClientRetries(urlSuffix,headers,'put',3,propertyFile, true); if(uploadresult.message.statusCode != 201){ - let uploadObj:any = await Util.getResultObj(uploadresult); - console.log(uploadObj ? uploadObj : Util.ErrorCorrection(uploadresult)); + let errorObj:any = await Util.getResultObj(uploadresult); + console.log(errorObj ? errorObj : Util.ErrorCorrection(uploadresult)); throw new Error("Error in uploading TestPlan for the created test"); } console.log(`Uploaded user properties file for the test successfully.`); @@ -306,7 +379,7 @@ export class APISupport { try { var startData = this.yamlModel.getStartTestData(); const testRunId = this.yamlModel.runTimeParams.testRunId; - let urlSuffix = "test-runs/"+testRunId+"?api-version=" + ApiVersionConstants.tm2024Version; + let urlSuffix = "test-runs/"+testRunId+"?api-version=" + ApiVersionConstants.latestVersion; urlSuffix = this.baseURL+urlSuffix; console.log("Creating and running a testRun for the test"); @@ -321,7 +394,7 @@ export class APISupport { let status = testRunDao.status; if(status == "ACCEPTED") { console.log("\nView the load test run in Azure portal by following the steps:") - console.log("1. Go to your Azure Load Testing resource '"+this.resourceName+"' in subscription '"+this.subName+"'") + console.log("1. Go to your Azure Load Testing resource '"+Util.getResourceNameFromResourceId(this.authContext.resourceId)+"' in subscription '"+Util.getSubscriptionIdFromResourceId(this.authContext.resourceId)+"'") console.log("2. On the Tests page, go to test '"+this.testId+"'") console.log("3. Go to test run '"+testRunDao.displayName+"'\n"); await this.getTestRunAPI(testRunId, status, startTime); @@ -336,7 +409,7 @@ export class APISupport { async getTestRunAPI(testRunId:string, testStatus:string, startTime : Date) { - let urlSuffix = "test-runs/"+testRunId+"?api-version=" + ApiVersionConstants.tm2024Version; + let urlSuffix = "test-runs/"+testRunId+"?api-version=" + ApiVersionConstants.latestVersion; urlSuffix = this.baseURL+urlSuffix; while(!Util.isTerminalTestStatus(testStatus)) { diff --git a/src/models/PayloadModels.ts b/src/models/PayloadModels.ts index c4f1352f..599ed295 100644 --- a/src/models/PayloadModels.ts +++ b/src/models/PayloadModels.ts @@ -27,6 +27,32 @@ export interface PassFailMetric { result?: string | null; }; +export interface AppComponentDefinition { + resourceName: string; + kind: string | null; + resourceId: string; + resourceType: string; + subscriptionId: string; + resourceGroup: string; +} + +export interface AppComponents { + components: {[key: string]: AppComponentDefinition | null}; +} + +export interface ServerMetricConfig { + metrics: { [key: string]: ResourceMetricModel | null }; +} + +export interface ResourceMetricModel { + name: string| null; + aggregation: string; + metricNamespace : string | null; + resourceId: string; + resourceType: string| null; + id: string; +} + export interface TestModel { testId?: string; description?: string; @@ -139,6 +165,7 @@ export interface ExistingParams { secrets: { [key: string]: SecretMetadata | null }; env: { [key: string]: string | null }; passFailCriteria: { [key: string]: PassFailMetric | null }; + appComponents: Map; // key: resourceId, value: guids of the app components, so that we can make them null when the resourceId is removed from the config file. } export enum ManagedIdentityTypeForAPI { diff --git a/src/models/TaskModels.ts b/src/models/TaskModels.ts index 0e298dc6..674257cf 100644 --- a/src/models/TaskModels.ts +++ b/src/models/TaskModels.ts @@ -6,8 +6,8 @@ import { TestKind } from "./engine/TestKind"; import { BaseLoadTestFrameworkModel } from "./engine/BaseLoadTestFrameworkModel"; const yaml = require('js-yaml'); import * as fs from 'fs'; -import { AutoStopCriteria, AutoStopCriteria as autoStopCriteriaObjOut, ManagedIdentityTypeForAPI } from "./PayloadModels"; -import { AllManagedIdentitiesSegregated, AutoStopCriteriaObjYaml, ParamType, ReferenceIdentityKinds, RunTimeParams } from "./UtilModels"; +import { AppComponentDefinition, AppComponents, AutoStopCriteria, AutoStopCriteria as autoStopCriteriaObjOut, ManagedIdentityTypeForAPI, ResourceMetricModel, ServerMetricConfig } from "./PayloadModels"; +import { AllManagedIdentitiesSegregated, AutoStopCriteriaObjYaml, ParamType, ReferenceIdentityKinds, RunTimeParams, ServerMetricsClientModel } from "./UtilModels"; import * as core from '@actions/core'; import { PassFailMetric, ExistingParams, TestModel, CertificateMetadata, SecretMetadata, RegionConfiguration } from "./PayloadModels"; @@ -43,6 +43,11 @@ export class YamlConfig { regionalLoadTestConfig: RegionConfiguration[] | null = null; runTimeParams: RunTimeParams = {env: {}, secrets: {}, runDisplayName: '', runDescription: '', testId: '', testRunId: ''}; + appComponents: { [key: string] : AppComponentDefinition | null } = {}; + serverMetricsConfig: { [key: string] : ResourceMetricModel | null } = {}; + + addDefaultsForAppComponents: { [key: string]: boolean } = {}; // when server components are not given for few app components, we need to add the defaults for this. + constructor() { let yamlFile = core.getInput('loadTestConfigFile') ?? ''; if(isNullOrUndefined(yamlFile) || yamlFile == ''){ @@ -125,6 +130,12 @@ export class YamlConfig { if(config.certificates != undefined){ this.certificates = this.parseParameters(config.certificates, ParamType.cert) as CertificateMetadata | null; } + + if(config.appComponents != undefined) { + let appcomponents = config.appComponents as Array; + this.getAppComponentsAndServerMetricsConfig(appcomponents); + } + if(config.keyVaultReferenceIdentity != undefined || config.keyVaultReferenceIdentityType != undefined) { this.keyVaultReferenceIdentityType = config.keyVaultReferenceIdentity ? ManagedIdentityTypeForAPI.UserAssigned : ManagedIdentityTypeForAPI.SystemAssigned; this.keyVaultReferenceIdentity = config.keyVaultReferenceIdentity ?? null; @@ -137,13 +148,7 @@ export class YamlConfig { if(config.regionalLoadTestConfig != undefined) { this.regionalLoadTestConfig = this.getMultiRegionLoadTestConfig(config.regionalLoadTestConfig); } - // commenting out for now, will re-write this logic with the changed options. - // if(config.engineBuiltInIdentityType != undefined) { - // engineBuiltInIdentityType = config.engineBuiltInIdentityType; - // } - // if(config.engineBuiltInIdentityIds != undefined) { - // engineBuiltInIdentityIds = config.engineBuiltInIdentityIds; - // } + if(this.testId === '' || isNullOrUndefined(this.testId) || this.testPlan === '' || isNullOrUndefined(this.testPlan)) { throw new Error("The required fields testId/testPlan are missing in "+yamlPath+"."); } @@ -151,6 +156,44 @@ export class YamlConfig { Util.validateTestRunParamsFromPipeline(this.runTimeParams); } + getAppComponentsAndServerMetricsConfig(appComponents: Array) { + for(let value of appComponents) { + let resourceId = value.resourceId.toLowerCase(); + this.appComponents[resourceId] = { + resourceName: (value.resourceName || Util.getResourceNameFromResourceId(resourceId)), + kind: value.kind ?? null, + resourceType: Util.getResourceTypeFromResourceId(resourceId) ?? '', + resourceId: resourceId, + subscriptionId: Util.getSubscriptionIdFromResourceId(resourceId) ?? '', + resourceGroup: Util.getResourceGroupFromResourceId(resourceId) ?? '' + }; + let metrics = (value.metrics ?? []) as Array; + + if(this.addDefaultsForAppComponents[resourceId] == undefined) { + this.addDefaultsForAppComponents[resourceId] = metrics.length == 0; + } else { + this.addDefaultsForAppComponents[resourceId] = this.addDefaultsForAppComponents[resourceId] && metrics.length == 0; + // when the same resource has metrics at one place, but not at other, we dont need defaults anymore. + } + + for(let serverComponent of metrics) { + let key : string = resourceId.toLowerCase() + '/' + (serverComponent.namespace ?? Util.getResourceTypeFromResourceId(resourceId)) + '/' + serverComponent.name; + if(!this.serverMetricsConfig.hasOwnProperty(key) || isNullOrUndefined(this.serverMetricsConfig[key])) { + this.serverMetricsConfig[key] = { + name: serverComponent.name, + aggregation: serverComponent.aggregation, + metricNamespace: serverComponent.namespace ?? Util.getResourceTypeFromResourceId(resourceId), + resourceId: resourceId, + resourceType: Util.getResourceTypeFromResourceId(resourceId) ?? '', + id: key + } + } else { + this.serverMetricsConfig[key].aggregation = this.serverMetricsConfig[key].aggregation + "," + serverComponent.aggregation; + } + } + } + } + getReferenceIdentities(referenceIdentities: {[key: string]: string}[]) { let segregatedManagedIdentities : AllManagedIdentitiesSegregated = Util.validateAndGetSegregatedManagedIdentities(referenceIdentities); @@ -255,6 +298,37 @@ export class YamlConfig { if(!this.env.hasOwnProperty(key)) this.env[key] = null; } + + + for(let [resourceId, keys] of existingData.appComponents) { + if(!this.appComponents.hasOwnProperty(resourceId.toLowerCase())) { + for(let key of keys) { + this.appComponents[key] = null; + } + } else { + for(let key of keys) { + if(key != null && key != resourceId.toLowerCase()) { + this.appComponents[key] = null; + } + } + } + } + } + + mergeExistingServerCriteria(existingServerCriteria: ServerMetricConfig) { + for(let key in existingServerCriteria.metrics) { + let resourceId = existingServerCriteria.metrics[key]?.resourceId?.toLowerCase() ?? ""; + if(this.addDefaultsForAppComponents.hasOwnProperty(resourceId) && !this.addDefaultsForAppComponents[resourceId] && !this.serverMetricsConfig.hasOwnProperty(key)) { + this.serverMetricsConfig[key] = null; + } + } + } + + getAppComponentsData() : AppComponents { + let appComponentsApiModel : AppComponents = { + components: this.appComponents + } + return appComponentsApiModel; } getCreateTestData(existingData:ExistingParams) { diff --git a/src/models/UtilModels.ts b/src/models/UtilModels.ts index 64e7777b..ab04afe9 100644 --- a/src/models/UtilModels.ts +++ b/src/models/UtilModels.ts @@ -66,8 +66,7 @@ export const resultZipFileName = 'results.zip'; export const correlationHeader = 'x-ms-correlation-request-id'; export module ApiVersionConstants { - export const tm2024Version = '2024-05-01-preview'; - export const tm2023Version = '2023-04-01-preview'; + export const latestVersion = '2024-12-01-preview'; export const tm2022Version = '2022-11-01'; export const cp2022Version = '2022-12-01' } @@ -93,7 +92,18 @@ export enum ManagedIdentityType { UserAssigned = "UserAssigned", } +export interface ServerMetricsClientModel { + name: string; + aggregation: string; + namespace?: string; +} + export interface AllManagedIdentitiesSegregated { referenceIdentityValuesUAMIMap: { [key in ReferenceIdentityKinds]: string[] }, referenceIdentiesSystemAssignedCount : { [key in ReferenceIdentityKinds]: number } +} + +export interface ValidationModel { + valid: boolean; + error: string; } \ No newline at end of file diff --git a/src/models/constants.ts b/src/models/constants.ts index 7a39654d..6ee71051 100644 --- a/src/models/constants.ts +++ b/src/models/constants.ts @@ -32,6 +32,37 @@ export const defaultYaml: any = 'percentage(error) > 50', { GetCustomerDetails: 'avg(latency) >200' } ], + appComponents: [ + { + resourceId: "/subscriptions/abcdef01-2345-6789-0abc-def012345678/resourceGroups/sample-rg/providers/Microsoft.Web/serverfarms/sampleApp", + kind: "app", + metrics:[ + { + name: "CpuPercentage", + aggregation: "Average" + }, + { + name: "MemoryPercentage", + aggregation: "Average", + namespace: "Microsoft.Web/serverfarms" + } + ], + }, + { + resourceId: "/subscriptions/abcdef01-2345-6789-0abc-def012345678/resourceGroups/sample-rg/providers/Microsoft.KeyVault/vaults/sampleApp", + metrics:[ + { + name: "ServiceApiHit", + aggregation: "Count", + namespace: "Microsoft.KeyVault/vaults" + }, + { + name: "ServiceApiLatency", + aggregation: "Average" + } + ] + } + ], autoStop: { errorPercentage: 80, timeWindow: 60 }, keyVaultReferenceIdentity: '/subscriptions/abcdef01-2345-6789-0abc-def012345678/resourceGroups/sample-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/sample-identity', keyVaultReferenceIdentityType: 'SystemAssigned', diff --git a/src/models/util.ts b/src/models/util.ts index 40fdd778..40072db4 100644 --- a/src/models/util.ts +++ b/src/models/util.ts @@ -6,7 +6,7 @@ import * as EngineUtil from './engine/Util'; import { BaseLoadTestFrameworkModel } from './engine/BaseLoadTestFrameworkModel'; import { TestKind } from "./engine/TestKind"; import { PassFailMetric, Statistics, TestRunArtifacts, TestRunModel, TestModel, ManagedIdentityTypeForAPI } from './PayloadModels'; -import { RunTimeParams, ValidAggregateList, ValidConditionList, ManagedIdentityType, PassFailCount, ReferenceIdentityKinds, AllManagedIdentitiesSegregated } from './UtilModels'; +import { RunTimeParams, ValidAggregateList, ValidConditionList, ManagedIdentityType, PassFailCount, ReferenceIdentityKinds, AllManagedIdentitiesSegregated, ValidationModel } from './UtilModels'; export function checkFileType(filePath: string, fileExtToValidate: string): boolean{ if(isNullOrUndefined(filePath)){ @@ -266,6 +266,13 @@ function isArrayOfStrings(variable: any): variable is string[] { return Array.isArray(variable) && variable.every((item) => typeof item === 'string'); } +function isInvalidString(variable: any, allowNull : boolean = false): variable is string[] { + if(allowNull){ + return !isNullOrUndefined(variable) && (typeof variable != 'string' || variable == ""); + } + return isNullOrUndefined(variable) || typeof variable != 'string' || variable == ""; +} + function inValidEngineInstances(engines : number) : boolean{ if(engines > 400 || engines < 1){ return true; @@ -273,6 +280,27 @@ function inValidEngineInstances(engines : number) : boolean{ return false; } +export function getResourceTypeFromResourceId(resourceId:string){ + return resourceId && resourceId.split("/").length > 7 ? resourceId.split("/")[6] + "/" + resourceId.split("/")[7] : null +} + +export function getResourceNameFromResourceId(resourceId:string){ + return resourceId && resourceId.split("/").length > 8 ? resourceId.split("/")[8] : null +} + +export function getResourceGroupFromResourceId(resourceId:string){ + return resourceId && resourceId.split("/").length > 4 ? resourceId.split("/")[4] : null +} + +export function getSubscriptionIdFromResourceId(resourceId:string){ + return resourceId && resourceId.split("/").length > 2 ? resourceId.split("/")[2] : null +} + +function isValidGUID(guid: string): boolean { + const guidRegex = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; + return guidRegex.test(guid); +} + export function checkValidityYaml(givenYaml : any) : {valid : boolean, error : string} { if(!isDictionary(givenYaml)) { return {valid : false,error :`Invalid YAML syntax.`}; @@ -383,6 +411,15 @@ export function checkValidityYaml(givenYaml : any) : {valid : boolean, error : s return {valid : false, error : `The value "${givenYaml.properties.userPropertyFile}" for userPropertyFile is invalid. Provide a valid file path of type ${framework.ClientResources.userPropertyFileExtensionsFriendly}. Refer to the YAML syntax at https://learn.microsoft.com/azure/load-testing/reference-test-config-yaml#properties-configuration.`} } } + if(givenYaml.appComponents) { + if(!Array.isArray(givenYaml.appComponents)){ + return {valid : false, error : `The value "${givenYaml.appComponents}" for appComponents is invalid. Provide a valid list of application components.`}; + } + let validationAppComponents = validateAppComponentAndServerComponents(givenYaml.appComponents); + if(validationAppComponents.valid == false){ + return validationAppComponents; + } + } if(givenYaml.autoStop){ if(typeof givenYaml.autoStop != 'string'){ if(isNullOrUndefined(givenYaml.autoStop.errorPercentage) || isNaN(givenYaml.autoStop.errorPercentage) || givenYaml.autoStop.errorPercentage > 100 || givenYaml.autoStop.errorPercentage < 0) { @@ -472,6 +509,58 @@ export function validateAndGetSegregatedManagedIdentities(referenceIdentities: { } return {referenceIdentityValuesUAMIMap, referenceIdentiesSystemAssignedCount}; } +function validateAppComponentAndServerComponents(appComponents: Array) : ValidationModel { + let appComponentsParsed = appComponents; + for(let i = 0; i < appComponentsParsed.length; i++){ + if(!isDictionary(appComponentsParsed[i])){ + return {valid : false, error : `The value "${appComponentsParsed[i].toString()}" for AppComponents in the index "${i}" is invalid. Provide a valid dictionary.`}; + } + let resourceId = appComponentsParsed[i].resourceId; + if(isInvalidString(resourceId)){ + return {valid : false, error : `The value "${appComponentsParsed[i].resourceId}" for resourceId in appComponents is invalid. Provide a valid resourceId.`}; + } + resourceId = resourceId.toLowerCase(); + let subscriptionId = getSubscriptionIdFromResourceId(resourceId); + let resourceType = getResourceTypeFromResourceId(resourceId); + let name = getResourceNameFromResourceId(resourceId); + let resourceGroup = getResourceGroupFromResourceId(resourceId); + if(isNullOrUndefined(resourceGroup) || isNullOrUndefined(subscriptionId) + || isNullOrUndefined(resourceType) || isNullOrUndefined(name) + || !isValidGUID(subscriptionId)){ + return {valid : false, error : `The value "${resourceId}" for resourceId in appComponents is invalid. Provide a valid resourceId.`}; + } + if(isInvalidString(appComponentsParsed[i].kind, true)){ + return {valid : false, error : `The value "${appComponentsParsed[i].kind?.toString()}" for kind in appComponents is invalid. Provide a valid string.`}; + } + if(isInvalidString(appComponentsParsed[i].resourceName, true)){ + return {valid : false, error : `The value "${appComponentsParsed[i].resourceName?.toString()}" for resourceName in appComponents is invalid. Provide a valid string.`}; + } + let resourceName = appComponentsParsed[i].resourceName || name; + if(!isNullOrUndefined(appComponentsParsed[i].metrics)) { + let metrics = appComponentsParsed[i].metrics; + if(!Array.isArray(metrics)){ + return {valid : false, error : `The value "${metrics?.toString()}" for metrics in the appComponent with resourceName "${resourceName}" is invalid. Provide a valid list of metrics.`}; + } + for(let metric of metrics){ + if(!isDictionary(metric)){ + return {valid : false, error : `The value "${metric?.toString()}" for metrics in the appComponent with resourceName "${resourceName}" is invalid. Provide a valid dictionary.`}; + } + if(metric && isInvalidString(metric.name)){ + return {valid : false, error : `The value "${metric.name?.toString()}" for name in the appComponent with resourceName "${resourceName}" is invalid. Provide a valid string.`}; + } + if(isInvalidString(metric.aggregation)){ + return {valid : false, error : `The value "${metric.aggregation?.toString()}" for aggregation in the appComponent with resourceName "${resourceName}" is invalid. Provide a valid string.`}; + } + if(isInvalidString(metric.namespace, true)){ + return {valid : false, error : `The value "${metric.namespace?.toString()}" for namespace in the appComponent with resourceName "${resourceName}" is invalid. Provide a valid string.`}; + } + } + } else { + console.log(`Metrics not provided for the appComponent "${resourceName}", default metrics will be enabled for the same.`); + } + } + return {valid : true, error : ""}; +} function validateReferenceIdentities(referenceIdentities: Array) : {valid : boolean, error : string} { for(let referenceIdentity of referenceIdentities){ @@ -524,6 +613,7 @@ export function getPassFailCriteriaFromString(passFailCriteria: (string | {[key: criteriaString = criteria[request] } let tempStr: string = ""; + for(let i=0; i{ describe('basic scenarios for invalid cases', ()=>{ @@ -241,6 +242,47 @@ describe('reference identity validations', () => { expect(checkValidityYaml(referenceIdentityConstants.referenceIdentityTypewithInvalidStringInKVID)).toStrictEqual({valid : false, error : `The value "UserAssigned,SystemAssigned" for type in referenceIdentities is invalid. Allowed values are "SystemAssigned" and "UserAssigned".`}); }); }); +describe('app components and server config tests', () => { + test('app components with metrics', () => { + expect(checkValidityYaml(appCompsConstants.appComponentsWithMetrics)).toStrictEqual({valid : true, error : ''}); + }); + test('without metrics and kind', () => { + expect(checkValidityYaml(appCompsConstants.appComponentsWithoutMetricsAndKind)).toStrictEqual({valid : true, error : ''}); + }); + + // invalid starts + test('invalid resource id as string', () => { + expect(checkValidityYaml(appCompsConstants.appCompsInvalidResourceIdString)).toStrictEqual({valid : false, error : 'The value "/subscriptions/abcdef01-2345-6789-0abc-def012345678/resourceGroups/sample-rg/providers/Microsoft.Web/serverfarms/sample-web" for resourceId in appComponents is invalid. Provide a valid resourceId.'}); + }); + // above one returns as it is string and this retuns the lowercase. + test('invalid resource id', () => { + expect(checkValidityYaml(appCompsConstants.appCompsInvalidResourceId)).toStrictEqual({valid : false, error : 'The value "/subscriptions/abcdef01-2345-6789-0abc-def012345678/resourcegroups/sample-rg/providers/microsoft.web/serverfarms" for resourceId in appComponents is invalid. Provide a valid resourceId.'}); + }); + test('invalid kind', () => { + expect(checkValidityYaml(appCompsConstants.appCompsInvalidKind)).toStrictEqual({valid : false, error : 'The value "test,test2" for kind in appComponents is invalid. Provide a valid string.'}); + }); + test('invalid resource name', () => { + expect(checkValidityYaml(appCompsConstants.appCompsInvalidResourceName)).toStrictEqual({valid : false, error : 'The value "test,test2" for resourceName in appComponents is invalid. Provide a valid string.'}); + }); + test('invalid metrics array', () => { + expect(checkValidityYaml(appCompsConstants.appCompsInvalidMetricsArray)).toStrictEqual({valid : false, error : 'The value "dummy" for metrics in the appComponent with resourceName "test" is invalid. Provide a valid list of metrics.'}); + }); + test('invalid metrics dictionary', () => { + expect(checkValidityYaml(appCompsConstants.appCompsInvalidMetricDict)).toStrictEqual({valid : false, error : 'The value "hi,123" for metrics in the appComponent with resourceName "test" is invalid. Provide a valid dictionary.'}); + }); + test('invalid metric name', () => { + expect(checkValidityYaml(appCompsConstants.appCompsInvalidMetricName)).toStrictEqual({valid : false, error : 'The value "123" for name in the appComponent with resourceName "test" is invalid. Provide a valid string.'}); + }); + test('invalid metric aggregation', () => { + expect(checkValidityYaml(appCompsConstants.appCompsInvalidMetricAggregation)).toStrictEqual({valid : false, error : 'The value "Average,Min" for aggregation in the appComponent with resourceName "test" is invalid. Provide a valid string.'}); + }); + test('invalid metric namepspace', () => { + expect(checkValidityYaml(appCompsConstants.appCompsInvalidMetricNameSpace)).toStrictEqual({valid : false, error : 'The value "dummy,dummy2" for namespace in the appComponent with resourceName "test" is invalid. Provide a valid string.'}); + }); + test('invalid app component dictionary', () => { + expect(checkValidityYaml(appCompsConstants.appCompsInvalidAppComponentDictionary)).toStrictEqual({valid : false, error : 'The value "hi,123" for AppComponents in the index "1" is invalid. Provide a valid dictionary.'}); + }); +}); describe('file errors', () => { test('Test object with no file validation errors', () => { // https://learn.microsoft.com/en-us/rest/api/loadtesting/dataplane/load-test-administration/get-test?view=rest-loadtesting-dataplane-2022-11-01&tabs=HTTP