diff --git a/src/handlers.ts b/src/handlers.ts index 8400efb..b46dda4 100644 --- a/src/handlers.ts +++ b/src/handlers.ts @@ -313,7 +313,7 @@ class Handlers { // found the group, so find the instances and act upon them // build the list of current instances const currentInventory = await this.instanceTracker.trimCurrent(req.context, req.params.name); - const instances = this.instanceTracker.mapToInstanceDetails(currentInventory); + const instances = InstanceTracker.mapToInstanceDetails(currentInventory); // set their reconfigure status to the current date try { await this.reconfigureManager.setReconfigureDate(req.context, instances); diff --git a/src/instance_launcher.ts b/src/instance_launcher.ts index a124a5c..0e3caf5 100644 --- a/src/instance_launcher.ts +++ b/src/instance_launcher.ts @@ -56,8 +56,6 @@ export default class InstanceLauncher { if (options.maxThrottleThreshold) { this.maxThrottleThreshold = options.maxThrottleThreshold; } - - this.launchOrShutdownInstancesByGroup = this.launchOrShutdownInstancesByGroup.bind(this); } async launchOrShutdownInstancesByGroup(ctx: Context, groupName: string): Promise { @@ -350,7 +348,7 @@ export default class InstanceLauncher { instanceState.status.provisioning == true ); }); - return this.instanceTracker.mapToInstanceDetails(states); + return InstanceTracker.mapToInstanceDetails(states); } private getRunningInstances(instanceStates: InstanceState[]): InstanceDetails[] { @@ -364,7 +362,7 @@ export default class InstanceLauncher { instanceState.status.provisioning == false ); }); - return this.instanceTracker.mapToInstanceDetails(states); + return InstanceTracker.mapToInstanceDetails(states); } private getAvailableJibris(instanceStates: InstanceState[]): InstanceDetails[] { @@ -373,7 +371,7 @@ export default class InstanceLauncher { instanceState.status.jibriStatus && instanceState.status.jibriStatus.busyStatus == JibriStatusState.Idle ); }); - return this.instanceTracker.mapToInstanceDetails(states); + return InstanceTracker.mapToInstanceDetails(states); } private getExpiredJibris(instanceStates: InstanceState[]): InstanceDetails[] { @@ -383,7 +381,7 @@ export default class InstanceLauncher { instanceState.status.jibriStatus.busyStatus == JibriStatusState.Expired ); }); - return this.instanceTracker.mapToInstanceDetails(states); + return InstanceTracker.mapToInstanceDetails(states); } private getBusyJibris(instanceStates: InstanceState[]): InstanceDetails[] { @@ -392,6 +390,6 @@ export default class InstanceLauncher { instanceState.status.jibriStatus && instanceState.status.jibriStatus.busyStatus == JibriStatusState.Busy ); }); - return this.instanceTracker.mapToInstanceDetails(states); + return InstanceTracker.mapToInstanceDetails(states); } } diff --git a/src/instance_tracker.ts b/src/instance_tracker.ts index 795d5a1..aa765d9 100644 --- a/src/instance_tracker.ts +++ b/src/instance_tracker.ts @@ -464,7 +464,7 @@ export class InstanceTracker { return states.filter((_, index) => !statesShutdownStatus[index] && !shutdownConfirmations[index]); } - mapToInstanceDetails(states: InstanceState[]): InstanceDetails[] { + static mapToInstanceDetails(states: InstanceState[]): InstanceDetails[] { return states.map((response) => { return { instanceId: response.instanceId, diff --git a/src/test/instance_launcher.ts b/src/test/instance_launcher.ts new file mode 100644 index 0000000..26bad0a --- /dev/null +++ b/src/test/instance_launcher.ts @@ -0,0 +1,177 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +// @ts-nocheck + +import assert from 'node:assert'; +import test, { afterEach, describe, mock } from 'node:test'; + +import InstanceTracker from '../instance_tracker'; +import InstanceLauncher from '../instance_launcher'; + +function log(msg, obj) { + console.log(msg, JSON.stringify(obj)); +} + +function initContext() { + return { + logger: { + info: mock.fn(log), + debug: mock.fn(log), + error: mock.fn(log), + warn: mock.fn(log), + }, + }; +} + +describe('InstanceLauncher', () => { + let context = initContext(); + + const shutdownManager = { + shutdown: mock.fn(), + areScaleDownProtected: mock.fn(() => []), + }; + + const audit = { + updateLastLauncherRun: mock.fn(), + saveLauncherActionItem: mock.fn(), + }; + + const groupName = 'group'; + const groupDetails = { + name: groupName, + type: 'JVB', + region: 'default', + environment: 'test', + compartmentId: 'test', + instanceConfigurationId: 'test', + enableAutoScale: true, + enableLaunch: true, + gracePeriodTTLSec: 480, + protectedTTLSec: 600, + scalingOptions: { + minDesired: 1, + maxDesired: 1, + desiredCount: 1, + scaleUpQuantity: 1, + scaleDownQuantity: 1, + scaleUpThreshold: 0.8, + scaleDownThreshold: 0.3, + scalePeriod: 60, + scaleUpPeriodsCount: 2, + scaleDownPeriodsCount: 2, + }, + }; + + const inventory = [{ instanceId: 'i-deadbeef007', status: { stats: { stress_level: 0.5, participants: 1 } } }]; + + const instanceGroupManager = { + getInstanceGroup: mock.fn(() => groupDetails), + isScaleDownProtected: mock.fn(() => false), + }; + + const cloudManager = { + scaleUp: mock.fn(() => groupDetails.scalingOptions.scaleUpQuantity), + scaleDown: mock.fn(), + }; + + const instanceTracker = { + trimCurrent: mock.fn(() => inventory), + mapToInstanceDetails: mock.fn((i) => InstanceTracker.mapToInstanceDetails(i)), + }; + + const metricsLoop = { + updateMetrics: mock.fn(), + getUnTrackedCount: mock.fn(() => 0), + }; + + // now we can create an instance of the class + const instanceLauncher = new InstanceLauncher({ + instanceTracker, + instanceGroupManager, + cloudManager, + shutdownManager, + audit, + metricsLoop, + }); + + afterEach(() => { + audit.updateLastLauncherRun.mock.resetCalls(); + instanceTracker.trimCurrent.mock.resetCalls(); + shutdownManager.areScaleDownProtected.mock.resetCalls(); + cloudManager.scaleDown.mock.resetCalls(); + cloudManager.scaleUp.mock.resetCalls(); + context = initContext(); + }); + + describe('instanceLauncher basic tests', () => { + // first test if disabled group exits correctly + test('launchOrShutdownInstancesByGroup should return false if group is disabled', async () => { + const groupDetailsDisabled = { ...groupDetails, enableLaunch: false }; + instanceGroupManager.getInstanceGroup.mock.mockImplementationOnce(() => groupDetailsDisabled); + const result = await instanceLauncher.launchOrShutdownInstancesByGroup(context, groupName); + assert.equal(result, false, 'skip disabled group'); + }); + + // now test if enable group does nothing with desired of 1 and inventory of 1 + test('launchOrShutdownInstancesByGroup should return true if desired is 1 and inventory is 1', async () => { + const result = await instanceLauncher.launchOrShutdownInstancesByGroup(context, groupName); + assert.equal(result, true, 'skip desired=1 and inventory=1'); + assert.equal(cloudManager.scaleUp.mock.calls.length, 0, 'no scaleUp'); + assert.equal(cloudManager.scaleDown.mock.calls.length, 0, 'no scaleDown'); + assert.equal(instanceTracker.trimCurrent.mock.calls.length, 1, 'trimCurrent called'); + assert.equal(audit.updateLastLauncherRun.mock.calls.length, 1, 'audit.updateLastLauncherRun called'); + assert.equal(context.logger.info.mock.calls.length, 1, 'logger.info called'); + assert.equal( + context.logger.info.mock.calls[0].arguments[0], + '[Launcher] No scaling activity needed for group group with 1 instances.', + ); + }); + + // now test if scaleDown occurs with desired of 0 and inventory of 1 + test('launchOrShutdownInstancesByGroup should return true if desired is 0 and inventory is 1', async () => { + const groupDetailsDesired0 = { + ...groupDetails, + scalingOptions: { ...groupDetails.scalingOptions, desiredCount: 0, minDesired: 0 }, + }; + instanceGroupManager.getInstanceGroup.mock.mockImplementationOnce(() => groupDetailsDesired0); + + const result = await instanceLauncher.launchOrShutdownInstancesByGroup(context, groupName); + assert.equal(result, true, 'scaleDown desired=0 and inventory=1'); + assert.equal(audit.updateLastLauncherRun.mock.calls.length, 1, 'audit.updateLastLauncherRun called'); + assert.equal(instanceTracker.trimCurrent.mock.calls.length, 1, 'trimCurrent called'); + assert.equal(cloudManager.scaleUp.mock.calls.length, 0, 'no scaleUp'); + assert.equal(cloudManager.scaleDown.mock.calls.length, 1, 'scaleDown called'); + assert.equal(shutdownManager.areScaleDownProtected.mock.calls.length, 1, 'areScaleDownProtected called'); + assert.equal(context.logger.info.mock.calls.length, 1, 'logger.info called'); + assert.equal( + context.logger.info.mock.calls[0].arguments[0], + '[Launcher] Will scale down to the desired count', + ); + }); + + // now test if scaleUp occurs with desired of 2 and inventory of 1 + test('launchOrShutdownInstancesByGroup should return true if desired is 2 and inventory is 1', async () => { + const groupDetailsDesired2 = { + ...groupDetails, + scalingOptions: { ...groupDetails.scalingOptions, desiredCount: 2, maxDesired: 2 }, + }; + instanceGroupManager.getInstanceGroup.mock.mockImplementationOnce(() => groupDetailsDesired2); + + const result = await instanceLauncher.launchOrShutdownInstancesByGroup(context, groupName); + assert.equal(result, true, 'scaleDown desired=0 and inventory=1'); + assert.equal(audit.updateLastLauncherRun.mock.calls.length, 1, 'audit.updateLastLauncherRun called'); + assert.equal(instanceTracker.trimCurrent.mock.calls.length, 1, 'trimCurrent called'); + assert.equal(cloudManager.scaleDown.mock.calls.length, 0, 'no scaleDown'); + assert.equal(cloudManager.scaleUp.mock.calls.length, 1, 'scaleUp called'); + assert.equal( + shutdownManager.areScaleDownProtected.mock.calls.length, + 0, + 'areScaleDownProtected not called', + ); + assert.equal(context.logger.info.mock.calls.length, 1, 'logger.info called'); + assert.equal( + context.logger.info.mock.calls[0].arguments[0], + '[Launcher] Will scale up to the desired count', + ); + }); + }); +});