/** * Copyright (c) HashiCorp, Inc. * SPDX-License-Identifier: MPL-2.0 */ import EmberObject from '@ember/object'; import { assign } from '@ember/polyfills'; import { module, test } from 'qunit'; import sinon from 'sinon'; import Pretender from 'pretender'; import AllocationStatsTracker, { stats, } from 'nomad-ui/utils/classes/allocation-stats-tracker'; import fetch from 'nomad-ui/utils/fetch'; import statsTrackerFrameMissingBehavior from './behaviors/stats-tracker-frame-missing'; import { settled } from '@ember/test-helpers'; module('Unit | Util | AllocationStatsTracker', function () { const refDate = Date.now() * 1000000; const makeDate = (ts) => new Date(ts / 1000000); const MockAllocation = (overrides) => assign( { id: 'some-identifier', taskGroup: { reservedCPU: 200, reservedMemory: 512, tasks: [ { name: 'log-shipper', reservedCPU: 50, reservedMemory: 128, lifecycleName: 'poststop', }, { name: 'service', reservedCPU: 100, reservedMemory: 256, lifecycleName: 'main', }, { name: 'sidecar', reservedCPU: 50, reservedMemory: 128, lifecycleName: 'prestart-sidecar', }, ], }, }, overrides ); const mockFrame = (step) => ({ ResourceUsage: { CpuStats: { TotalTicks: step + 100, }, MemoryStats: { RSS: (step + 400) * 1024 * 1024, Usage: (step + 800) * 1024 * 1024, }, }, Tasks: { service: { ResourceUsage: { CpuStats: { TotalTicks: step + 50, }, MemoryStats: { RSS: (step + 100) * 1024 * 1024, Usage: (step + 200) * 1024 * 1024, }, }, Timestamp: refDate + step, }, 'log-shipper': { ResourceUsage: { CpuStats: { TotalTicks: step + 25, }, MemoryStats: { RSS: (step + 50) * 1024 * 1024, Usage: (step + 100) * 1024 * 1024, }, }, Timestamp: refDate + step * 10, }, sidecar: { ResourceUsage: { CpuStats: { TotalTicks: step + 26, }, MemoryStats: { RSS: (step + 51) * 1024 * 1024, Usage: (step + 101) * 1024 * 1024, }, }, Timestamp: refDate + step * 100, }, }, Timestamp: refDate + step * 1000, }); test('the AllocationStatsTracker constructor expects a fetch definition and an allocation', async function (assert) { const tracker = AllocationStatsTracker.create(); assert.throws( () => { tracker.fetch(); }, /StatsTrackers need a fetch method/, 'Polling does not work without a fetch method provided' ); }); test('the url property is computed based off the allocation id', async function (assert) { const allocation = MockAllocation(); const tracker = AllocationStatsTracker.create({ fetch, allocation }); assert.equal( tracker.get('url'), `/v1/client/allocation/${allocation.id}/stats`, 'Url is derived from the allocation id' ); }); test('reservedCPU and reservedMemory properties come from the allocation', async function (assert) { const allocation = MockAllocation(); const tracker = AllocationStatsTracker.create({ fetch, allocation }); assert.equal( tracker.get('reservedCPU'), allocation.taskGroup.reservedCPU, 'reservedCPU comes from the allocation task group' ); assert.equal( tracker.get('reservedMemory'), allocation.taskGroup.reservedMemory, 'reservedMemory comes from the allocation task group' ); }); test('the tasks list comes from the allocation', async function (assert) { assert.expect(7); const allocation = MockAllocation(); const tracker = AllocationStatsTracker.create({ fetch, allocation }); assert.equal( tracker.get('tasks.length'), allocation.taskGroup.tasks.length, 'tasks matches lengths with the allocation task group' ); allocation.taskGroup.tasks.forEach((task) => { const trackerTask = tracker.get('tasks').findBy('task', task.name); assert.equal( trackerTask.reservedCPU, task.reservedCPU, `CPU matches for task ${task.name}` ); assert.equal( trackerTask.reservedMemory, task.reservedMemory, `Memory matches for task ${task.name}` ); }); }); test('poll results in requesting the url and calling append with the resulting JSON', async function (assert) { const allocation = MockAllocation(); const tracker = AllocationStatsTracker.create({ fetch, allocation, append: sinon.spy(), }); const mockFrame = { Some: { data: ['goes', 'here'], twelve: 12, }, }; const server = new Pretender(function () { this.get('/v1/client/allocation/:id/stats', () => [ 200, {}, JSON.stringify(mockFrame), ]); }); tracker.get('poll').perform(); assert.equal(server.handledRequests.length, 1, 'Only one request was made'); assert.equal( server.handledRequests[0].url, `/v1/client/allocation/${allocation.id}/stats`, 'The correct URL was requested' ); await settled(); assert.ok( tracker.append.calledWith(mockFrame), 'The JSON response was passed onto append as a POJO' ); server.shutdown(); }); test('append appropriately maps a data frame to the tracked stats for cpu and memory for the allocation as well as individual tasks', async function (assert) { const allocation = MockAllocation(); const tracker = AllocationStatsTracker.create({ fetch, allocation }); assert.deepEqual(tracker.get('cpu'), [], 'No tracked cpu yet'); assert.deepEqual(tracker.get('memory'), [], 'No tracked memory yet'); assert.deepEqual( tracker.get('tasks'), [ { task: 'service', reservedCPU: 100, reservedMemory: 256, cpu: [], memory: [], }, { task: 'sidecar', reservedCPU: 50, reservedMemory: 128, cpu: [], memory: [], }, { task: 'log-shipper', reservedCPU: 50, reservedMemory: 128, cpu: [], memory: [], }, ], 'tasks represents the tasks for the allocation with no stats yet' ); tracker.append(mockFrame(1)); assert.deepEqual( tracker.get('cpu'), [{ timestamp: makeDate(refDate + 1000), used: 101, percent: 101 / 200 }], 'One frame of cpu' ); assert.deepEqual( tracker.get('memory'), [ { timestamp: makeDate(refDate + 1000), used: 401 * 1024 * 1024, percent: 401 / 512, }, ], 'One frame of memory' ); assert.deepEqual( tracker.get('tasks'), [ { task: 'service', reservedCPU: 100, reservedMemory: 256, cpu: [ { timestamp: makeDate(refDate + 1), used: 51, percent: 51 / 100, percentStack: 51 / (100 + 50 + 50), percentTotal: 51 / (100 + 50 + 50), }, ], memory: [ { timestamp: makeDate(refDate + 1), used: 101 * 1024 * 1024, percent: 101 / 256, percentStack: 101 / (256 + 128 + 128), percentTotal: 101 / (256 + 128 + 128), }, ], }, { task: 'sidecar', reservedCPU: 50, reservedMemory: 128, cpu: [ { timestamp: makeDate(refDate + 100), used: 27, percent: 27 / 50, percentStack: (27 + 51) / (100 + 50 + 50), percentTotal: 27 / (100 + 50 + 50), }, ], memory: [ { timestamp: makeDate(refDate + 100), used: 52 * 1024 * 1024, percent: 52 / 128, percentStack: (52 + 101) / (256 + 128 + 128), percentTotal: 52 / (256 + 128 + 128), }, ], }, { task: 'log-shipper', reservedCPU: 50, reservedMemory: 128, cpu: [ { timestamp: makeDate(refDate + 10), used: 26, percent: 26 / 50, percentStack: (26 + 27 + 51) / (100 + 50 + 50), percentTotal: 26 / (100 + 50 + 50), }, ], memory: [ { timestamp: makeDate(refDate + 10), used: 51 * 1024 * 1024, percent: 51 / 128, percentStack: (51 + 52 + 101) / (256 + 128 + 128), percentTotal: 51 / (256 + 128 + 128), }, ], }, ], 'tasks represents the tasks for the allocation, each with one frame of stats' ); tracker.append(mockFrame(2)); assert.deepEqual( tracker.get('cpu'), [ { timestamp: makeDate(refDate + 1000), used: 101, percent: 101 / 200 }, { timestamp: makeDate(refDate + 2000), used: 102, percent: 102 / 200 }, ], 'Two frames of cpu' ); assert.deepEqual( tracker.get('memory'), [ { timestamp: makeDate(refDate + 1000), used: 401 * 1024 * 1024, percent: 401 / 512, }, { timestamp: makeDate(refDate + 2000), used: 402 * 1024 * 1024, percent: 402 / 512, }, ], 'Two frames of memory' ); assert.deepEqual( tracker.get('tasks'), [ { task: 'service', reservedCPU: 100, reservedMemory: 256, cpu: [ { timestamp: makeDate(refDate + 1), used: 51, percent: 51 / 100, percentStack: 51 / (100 + 50 + 50), percentTotal: 51 / (100 + 50 + 50), }, { timestamp: makeDate(refDate + 2), used: 52, percent: 52 / 100, percentStack: 52 / (100 + 50 + 50), percentTotal: 52 / (100 + 50 + 50), }, ], memory: [ { timestamp: makeDate(refDate + 1), used: 101 * 1024 * 1024, percent: 101 / 256, percentStack: 101 / (256 + 128 + 128), percentTotal: 101 / (256 + 128 + 128), }, { timestamp: makeDate(refDate + 2), used: 102 * 1024 * 1024, percent: 102 / 256, percentStack: 102 / (256 + 128 + 128), percentTotal: 102 / (256 + 128 + 128), }, ], }, { task: 'sidecar', reservedCPU: 50, reservedMemory: 128, cpu: [ { timestamp: makeDate(refDate + 100), used: 27, percent: 27 / 50, percentStack: (27 + 51) / (100 + 50 + 50), percentTotal: 27 / (100 + 50 + 50), }, { timestamp: makeDate(refDate + 200), used: 28, percent: 28 / 50, percentStack: (28 + 52) / (100 + 50 + 50), percentTotal: 28 / (100 + 50 + 50), }, ], memory: [ { timestamp: makeDate(refDate + 100), used: 52 * 1024 * 1024, percent: 52 / 128, percentStack: (52 + 101) / (256 + 128 + 128), percentTotal: 52 / (256 + 128 + 128), }, { timestamp: makeDate(refDate + 200), used: 53 * 1024 * 1024, percent: 53 / 128, percentStack: (53 + 102) / (256 + 128 + 128), percentTotal: 53 / (256 + 128 + 128), }, ], }, { task: 'log-shipper', reservedCPU: 50, reservedMemory: 128, cpu: [ { timestamp: makeDate(refDate + 10), used: 26, percent: 26 / 50, percentStack: (26 + 27 + 51) / (100 + 50 + 50), percentTotal: 26 / (100 + 50 + 50), }, { timestamp: makeDate(refDate + 20), used: 27, percent: 27 / 50, percentStack: (27 + 28 + 52) / (100 + 50 + 50), percentTotal: 27 / (100 + 50 + 50), }, ], memory: [ { timestamp: makeDate(refDate + 10), used: 51 * 1024 * 1024, percent: 51 / 128, percentStack: (51 + 52 + 101) / (256 + 128 + 128), percentTotal: 51 / (256 + 128 + 128), }, { timestamp: makeDate(refDate + 20), used: 52 * 1024 * 1024, percent: 52 / 128, percentStack: (52 + 53 + 102) / (256 + 128 + 128), percentTotal: 52 / (256 + 128 + 128), }, ], }, ], 'tasks represents the tasks for the allocation, each with two frames of stats' ); }); test('each stat list has maxLength equal to bufferSize', async function (assert) { assert.expect(16); const allocation = MockAllocation(); const bufferSize = 10; const tracker = AllocationStatsTracker.create({ fetch, allocation, bufferSize, }); for (let i = 1; i <= 20; i++) { tracker.append(mockFrame(i)); } assert.equal( tracker.get('cpu.length'), bufferSize, `20 calls to append, only ${bufferSize} frames in the stats array` ); assert.equal( tracker.get('memory.length'), bufferSize, `20 calls to append, only ${bufferSize} frames in the stats array` ); assert.equal( +tracker.get('cpu')[0].timestamp, +makeDate(refDate + 11000), 'Old frames are removed in favor of newer ones' ); assert.equal( +tracker.get('memory')[0].timestamp, +makeDate(refDate + 11000), 'Old frames are removed in favor of newer ones' ); tracker.get('tasks').forEach((task) => { assert.equal( task.cpu.length, bufferSize, `20 calls to append, only ${bufferSize} frames in the stats array` ); assert.equal( task.memory.length, bufferSize, `20 calls to append, only ${bufferSize} frames in the stats array` ); }); assert.equal( +tracker.get('tasks').findBy('task', 'service').cpu[0].timestamp, +makeDate(refDate + 11), 'Old frames are removed in favor of newer ones' ); assert.equal( +tracker.get('tasks').findBy('task', 'service').memory[0].timestamp, +makeDate(refDate + 11), 'Old frames are removed in favor of newer ones' ); assert.equal( +tracker.get('tasks').findBy('task', 'log-shipper').cpu[0].timestamp, +makeDate(refDate + 110), 'Old frames are removed in favor of newer ones' ); assert.equal( +tracker.get('tasks').findBy('task', 'log-shipper').memory[0].timestamp, +makeDate(refDate + 110), 'Old frames are removed in favor of newer ones' ); assert.equal( +tracker.get('tasks').findBy('task', 'sidecar').cpu[0].timestamp, +makeDate(refDate + 1100), 'Old frames are removed in favor of newer ones' ); assert.equal( +tracker.get('tasks').findBy('task', 'sidecar').memory[0].timestamp, +makeDate(refDate + 1100), 'Old frames are removed in favor of newer ones' ); }); test('the stats computed property macro constructs an AllocationStatsTracker based on an allocationProp and a fetch definition', async function (assert) { const allocation = MockAllocation(); const fetchSpy = sinon.spy(); const SomeClass = EmberObject.extend({ stats: stats('alloc', function () { return () => fetchSpy(this); }), }); const someObject = SomeClass.create({ alloc: allocation, }); assert.equal( someObject.get('stats.url'), `/v1/client/allocation/${allocation.id}/stats`, 'stats computed property macro creates an AllocationStatsTracker' ); someObject.get('stats').fetch(); assert.ok( fetchSpy.calledWith(someObject), 'the fetch factory passed into the macro gets called to assign a bound version of fetch to the AllocationStatsTracker instance' ); }); test('changing the value of the allocationProp constructs a new AllocationStatsTracker', async function (assert) { const alloc1 = MockAllocation(); const alloc2 = MockAllocation(); const SomeClass = EmberObject.extend({ stats: stats('alloc', () => fetch), }); const someObject = SomeClass.create({ alloc: alloc1, }); const stats1 = someObject.get('stats'); someObject.set('alloc', alloc2); const stats2 = someObject.get('stats'); assert.notStrictEqual( stats1, stats2, 'Changing the value of alloc results in creating a new AllocationStatsTracker instance' ); }); statsTrackerFrameMissingBehavior({ resourceName: 'allocation', ResourceConstructor: MockAllocation, TrackerConstructor: AllocationStatsTracker, mockFrame, compileResources(frame) { const timestamp = makeDate(frame.Timestamp); const cpu = frame.ResourceUsage.CpuStats.TotalTicks; const memory = frame.ResourceUsage.MemoryStats.RSS; return [ { timestamp, used: cpu, percent: cpu / 200, }, { timestamp, used: memory, percent: memory / 1024 / 1024 / 512, }, ]; }, }); });