/** * Copyright (c) HashiCorp, Inc. * SPDX-License-Identifier: MPL-2.0 */ /* eslint-disable qunit/require-expect */ /* eslint-disable qunit/no-conditional-assertions */ import { currentURL, settled } from '@ember/test-helpers'; import { module, test } from 'qunit'; import { setupApplicationTest } from 'ember-qunit'; import { setupMirage } from 'ember-cli-mirage/test-support'; import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit'; import { formatBytes, formatHertz, formatScheduledBytes, formatScheduledHertz, } from 'nomad-ui/utils/units'; import TaskGroup from 'nomad-ui/tests/pages/jobs/job/task-group'; import Layout from 'nomad-ui/tests/pages/layout'; import pageSizeSelect from './behaviors/page-size-select'; import moment from 'moment'; let job; let taskGroup; let tasks; let allocations; let managementToken; const sum = (total, n) => total + n; module('Acceptance | task group detail', function (hooks) { setupApplicationTest(hooks); setupMirage(hooks); hooks.beforeEach(async function () { server.create('agent'); server.create('node', 'forceIPv4'); job = server.create('job', { groupsCount: 2, createAllocations: false, }); const taskGroups = server.db.taskGroups.where({ jobId: job.id }); taskGroup = taskGroups[0]; tasks = taskGroup.taskIds.map((id) => server.db.tasks.find(id)); server.create('node', 'forceIPv4'); allocations = server.createList('allocation', 2, { jobId: job.id, taskGroup: taskGroup.name, clientStatus: 'running', }); // Allocations associated to a different task group on the job to // assert that they aren't showing up in on this page in error. server.createList('allocation', 3, { jobId: job.id, taskGroup: taskGroups[1].name, clientStatus: 'running', }); // Set a static name to make the search test deterministic server.db.allocations.forEach((alloc) => { alloc.name = 'aaaaa'; }); // Mark the first alloc as rescheduled allocations[0].update({ nextAllocation: allocations[1].id, }); allocations[1].update({ previousAllocation: allocations[0].id, }); managementToken = server.create('token'); window.localStorage.clear(); }); test('it passes an accessibility audit', async function (assert) { await TaskGroup.visit({ id: job.id, name: taskGroup.name }); await a11yAudit(assert); }); test('/jobs/:id/:task-group should list high-level metrics for the allocation', async function (assert) { const totalCPU = tasks.mapBy('resources.CPU').reduce(sum, 0); const totalMemory = tasks.mapBy('resources.MemoryMB').reduce(sum, 0); const totalMemoryMax = tasks .map((t) => t.resources.MemoryMaxMB || t.resources.MemoryMB) .reduce(sum, 0); const totalDisk = taskGroup.ephemeralDisk.SizeMB; await TaskGroup.visit({ id: job.id, name: taskGroup.name }); assert.equal(TaskGroup.tasksCount, `# Tasks ${tasks.length}`, '# Tasks'); assert.equal( TaskGroup.cpu, `Reserved CPU ${formatScheduledHertz(totalCPU, 'MHz')}`, 'Aggregated CPU reservation for all tasks' ); let totalMemoryMaxAddendum = ''; if (totalMemoryMax > totalMemory) { totalMemoryMaxAddendum = ` (${formatScheduledBytes( totalMemoryMax, 'MiB' )}Max)`; } assert.equal( TaskGroup.mem, `Reserved Memory ${formatScheduledBytes( totalMemory, 'MiB' )}${totalMemoryMaxAddendum}`, 'Aggregated Memory reservation for all tasks' ); assert.equal( TaskGroup.disk, `Reserved Disk ${formatScheduledBytes(totalDisk, 'MiB')}`, 'Aggregated Disk reservation for all tasks' ); assert.equal( document.title, `Task group ${taskGroup.name} - Job ${job.name} - Mirage - Nomad` ); }); test('/jobs/:id/:task-group should have breadcrumbs for job and jobs', async function (assert) { await TaskGroup.visit({ id: job.id, name: taskGroup.name }); assert.equal( Layout.breadcrumbFor('jobs.index').text, 'Jobs', 'First breadcrumb says jobs' ); assert.equal( Layout.breadcrumbFor('jobs.job.index').text, `Job ${job.name}`, 'Second breadcrumb says the job name' ); assert.equal( Layout.breadcrumbFor('jobs.job.task-group').text, `Task Group ${taskGroup.name}`, 'Third breadcrumb says the job name' ); }); test('/jobs/:id/:task-group first breadcrumb should link to jobs', async function (assert) { await TaskGroup.visit({ id: job.id, name: taskGroup.name }); await Layout.breadcrumbFor('jobs.index').visit(); assert.equal(currentURL(), '/jobs', 'First breadcrumb links back to jobs'); }); test('/jobs/:id/:task-group second breadcrumb should link to the job for the task group', async function (assert) { await TaskGroup.visit({ id: job.id, name: taskGroup.name }); await Layout.breadcrumbFor('jobs.job.index').visit(); assert.equal( currentURL(), `/jobs/${job.id}`, 'Second breadcrumb links back to the job for the task group' ); }); test('when the user has a client token that has a namespace with a policy to run and scale a job the autoscaler options should be available', async function (assert) { window.localStorage.clear(); const SCALE_AND_WRITE_NAMESPACE = 'scale-and-write-namespace'; const READ_ONLY_NAMESPACE = 'read-only-namespace'; const clientToken = server.create('token'); server.create('namespace', { id: SCALE_AND_WRITE_NAMESPACE }); const secondNamespace = server.create('namespace', { id: READ_ONLY_NAMESPACE, }); job = server.create('job', { groupCount: 0, createAllocations: false, shallow: true, noActiveDeployment: true, namespaceId: SCALE_AND_WRITE_NAMESPACE, }); const scalingGroup = server.create('task-group', { job, name: 'scaling', count: 1, shallow: true, withScaling: true, }); job.update({ taskGroupIds: [scalingGroup.id] }); const job2 = server.create('job', { groupCount: 0, createAllocations: false, shallow: true, noActiveDeployment: true, namespaceId: READ_ONLY_NAMESPACE, }); const scalingGroup2 = server.create('task-group', { job: job2, name: 'scaling', count: 1, shallow: true, withScaling: true, }); job2.update({ taskGroupIds: [scalingGroup2.id] }); const policy = server.create('policy', { id: 'something', name: 'something', rulesJSON: { Namespaces: [ { Name: SCALE_AND_WRITE_NAMESPACE, Capabilities: ['scale-job', 'submit-job', 'read-job', 'list-jobs'], }, { Name: READ_ONLY_NAMESPACE, Capabilities: ['list-jobs', 'read-job'], }, ], }, }); clientToken.policyIds = [policy.id]; clientToken.save(); window.localStorage.nomadTokenSecret = clientToken.secretId; await TaskGroup.visit({ id: `${job.id}@${SCALE_AND_WRITE_NAMESPACE}`, name: scalingGroup.name, }); assert.equal( decodeURIComponent(currentURL()), `/jobs/${job.id}@${SCALE_AND_WRITE_NAMESPACE}/scaling` ); assert.notOk(TaskGroup.countStepper.increment.isDisabled); await TaskGroup.visit({ id: `${job2.id}@${secondNamespace.name}`, name: scalingGroup2.name, }); assert.equal( decodeURIComponent(currentURL()), `/jobs/${job2.id}@${READ_ONLY_NAMESPACE}/scaling` ); assert.ok(TaskGroup.countStepper.increment.isDisabled); }); test('/jobs/:id/:task-group should list one page of allocations for the task group', async function (assert) { server.createList('allocation', TaskGroup.pageSize, { jobId: job.id, taskGroup: taskGroup.name, clientStatus: 'running', }); await TaskGroup.visit({ id: job.id, name: taskGroup.name }); assert.ok( server.db.allocations.where({ jobId: job.id }).length > TaskGroup.pageSize, 'There are enough allocations to invoke pagination' ); assert.equal( TaskGroup.allocations.length, TaskGroup.pageSize, 'All allocations for the task group' ); }); test('each allocation should show basic information about the allocation', async function (assert) { await TaskGroup.visit({ id: job.id, name: taskGroup.name }); const allocation = allocations.sortBy('modifyIndex').reverse()[0]; const allocationRow = TaskGroup.allocations.objectAt(0); assert.equal( allocationRow.shortId, allocation.id.split('-')[0], 'Allocation short id' ); assert.equal( allocationRow.createTime, moment(allocation.createTime / 1000000).format('MMM DD HH:mm:ss ZZ'), 'Allocation create time' ); assert.equal( allocationRow.modifyTime, moment(allocation.modifyTime / 1000000).fromNow(), 'Allocation modify time' ); assert.equal( allocationRow.status, allocation.clientStatus, 'Client status' ); assert.equal( allocationRow.jobVersion, allocation.jobVersion, 'Job Version' ); assert.equal( allocationRow.client, server.db.nodes.find(allocation.nodeId).id.split('-')[0], 'Node ID' ); assert.equal( allocationRow.volume, Object.keys(taskGroup.volumes).length ? 'Yes' : '', 'Volumes' ); await allocationRow.visitClient(); assert.equal( currentURL(), `/clients/${allocation.nodeId}`, 'Node links to node page' ); }); test('each allocation should show stats about the allocation', async function (assert) { await TaskGroup.visit({ id: job.id, name: taskGroup.name }); const allocation = allocations.sortBy('name')[0]; const allocationRow = TaskGroup.allocations.objectAt(0); const allocStats = server.db.clientAllocationStats.find(allocation.id); const tasks = taskGroup.taskIds.map((id) => server.db.tasks.find(id)); const cpuUsed = tasks.reduce((sum, task) => sum + task.resources.CPU, 0); const memoryUsed = tasks.reduce( (sum, task) => sum + task.resources.MemoryMB, 0 ); assert.equal( allocationRow.cpu, Math.floor(allocStats.resourceUsage.CpuStats.TotalTicks) / cpuUsed, 'CPU %' ); const roundedTicks = Math.floor( allocStats.resourceUsage.CpuStats.TotalTicks ); assert.equal( allocationRow.cpuTooltip, `${formatHertz(roundedTicks, 'MHz')} / ${formatHertz(cpuUsed, 'MHz')}`, 'Detailed CPU information is in a tooltip' ); assert.equal( allocationRow.mem, allocStats.resourceUsage.MemoryStats.RSS / 1024 / 1024 / memoryUsed, 'Memory used' ); assert.equal( allocationRow.memTooltip, `${formatBytes(allocStats.resourceUsage.MemoryStats.RSS)} / ${formatBytes( memoryUsed, 'MiB' )}`, 'Detailed memory information is in a tooltip' ); }); test('when the allocation search has no matches, there is an empty message', async function (assert) { await TaskGroup.visit({ id: job.id, name: taskGroup.name }); await TaskGroup.search('zzzzzz'); assert.ok(TaskGroup.isEmpty, 'Empty state is shown'); assert.equal( TaskGroup.emptyState.headline, 'No Matches', 'Empty state has an appropriate message' ); }); test('when the allocation has reschedule events, the allocation row is denoted with an icon', async function (assert) { await TaskGroup.visit({ id: job.id, name: taskGroup.name }); const rescheduleRow = TaskGroup.allocationFor(allocations[0].id); const normalRow = TaskGroup.allocationFor(allocations[1].id); assert.ok( rescheduleRow.rescheduled, 'Reschedule row has a reschedule icon' ); assert.notOk(normalRow.rescheduled, 'Normal row has no reschedule icon'); }); test('/jobs/:id/:task-group should present task lifecycles', async function (assert) { job = server.create('job', { groupsCount: 2, groupTaskCount: 3, }); const taskGroups = server.db.taskGroups.where({ jobId: job.id }); taskGroup = taskGroups[0]; await TaskGroup.visit({ id: job.id, name: taskGroup.name }); assert.ok(TaskGroup.lifecycleChart.isPresent); assert.equal( TaskGroup.lifecycleChart.title, 'Task Lifecycle Configuration' ); tasks = taskGroup.taskIds.map((id) => server.db.tasks.find(id)); const taskNames = tasks.mapBy('name'); // This is thoroughly tested in allocation detail tests, so this mostly checks what’s different assert.equal(TaskGroup.lifecycleChart.tasks.length, 3); TaskGroup.lifecycleChart.tasks.forEach((Task) => { assert.ok(taskNames.includes(Task.name)); assert.notOk(Task.isActive); assert.notOk(Task.isFinished); }); }); test('when the task group depends on volumes, the volumes table is shown', async function (assert) { await TaskGroup.visit({ id: job.id, name: taskGroup.name }); assert.ok(TaskGroup.hasVolumes); assert.equal( TaskGroup.volumes.length, Object.keys(taskGroup.volumes).length ); }); test('when the task group does not depend on volumes, the volumes table is not shown', async function (assert) { job = server.create('job', { noHostVolumes: true, shallow: true }); taskGroup = server.db.taskGroups.where({ jobId: job.id })[0]; await TaskGroup.visit({ id: job.id, name: taskGroup.name }); assert.notOk(TaskGroup.hasVolumes); }); test('each row in the volumes table lists information about the volume', async function (assert) { await TaskGroup.visit({ id: job.id, name: taskGroup.name }); TaskGroup.volumes[0].as((volumeRow) => { const volume = taskGroup.volumes[volumeRow.name]; assert.equal(volumeRow.name, volume.Name); assert.equal(volumeRow.type, volume.Type); assert.equal(volumeRow.source, volume.Source); assert.equal( volumeRow.permissions, volume.ReadOnly ? 'Read' : 'Read/Write' ); }); }); test('the count stepper sends the appropriate POST request', async function (assert) { window.localStorage.nomadTokenSecret = managementToken.secretId; job = server.create('job', { groupCount: 0, createAllocations: false, shallow: true, noActiveDeployment: true, }); const scalingGroup = server.create('task-group', { job, name: 'scaling', count: 1, shallow: true, withScaling: true, }); job.update({ taskGroupIds: [scalingGroup.id] }); await TaskGroup.visit({ id: job.id, name: scalingGroup.name }); await TaskGroup.countStepper.increment.click(); await settled(); const scaleRequest = server.pretender.handledRequests.find( (req) => req.method === 'POST' && req.url.endsWith('/scale') ); const requestBody = JSON.parse(scaleRequest.requestBody); assert.equal(requestBody.Target.Group, scalingGroup.name); assert.equal(requestBody.Count, scalingGroup.count + 1); }); test('the count stepper is disabled when a deployment is running', async function (assert) { window.localStorage.nomadTokenSecret = managementToken.secretId; job = server.create('job', { groupCount: 0, createAllocations: false, shallow: true, activeDeployment: true, }); const scalingGroup = server.create('task-group', { job, name: 'scaling', count: 1, shallow: true, withScaling: true, }); job.update({ taskGroupIds: [scalingGroup.id] }); await TaskGroup.visit({ id: job.id, name: scalingGroup.name }); assert.ok(TaskGroup.countStepper.input.isDisabled); assert.ok(TaskGroup.countStepper.increment.isDisabled); assert.ok(TaskGroup.countStepper.decrement.isDisabled); }); test('when the job for the task group is not found, an error message is shown, but the URL persists', async function (assert) { await TaskGroup.visit({ id: 'not-a-real-job', name: 'not-a-real-task-group', }); assert.equal( server.pretender.handledRequests .filter((request) => !request.url.includes('policy')) .findBy('status', 404).url, '/v1/job/not-a-real-job', 'A request to the nonexistent job is made' ); assert.equal( currentURL(), '/jobs/not-a-real-job/not-a-real-task-group', 'The URL persists' ); assert.ok(TaskGroup.error.isPresent, 'Error message is shown'); assert.equal( TaskGroup.error.title, 'Not Found', 'Error message is for 404' ); }); test('when the task group is not found on the job, an error message is shown, but the URL persists', async function (assert) { await TaskGroup.visit({ id: job.id, name: 'not-a-real-task-group' }); assert.ok( server.pretender.handledRequests .filterBy('status', 200) .mapBy('url') .includes(`/v1/job/${job.id}`), 'A request to the job is made and succeeds' ); assert.equal( currentURL(), `/jobs/${job.id}/not-a-real-task-group`, 'The URL persists' ); assert.ok(TaskGroup.error.isPresent, 'Error message is shown'); assert.equal( TaskGroup.error.title, 'Not Found', 'Error message is for 404' ); }); pageSizeSelect({ resourceName: 'allocation', pageObject: TaskGroup, pageObjectList: TaskGroup.allocations, async setup() { server.createList('allocation', TaskGroup.pageSize, { jobId: job.id, taskGroup: taskGroup.name, clientStatus: 'running', }); await TaskGroup.visit({ id: job.id, name: taskGroup.name }); }, }); test('when a task group has no scaling events, there is no recent scaling events section', async function (assert) { const taskGroupScale = job.jobScale.taskGroupScales.models.find( (m) => m.name === taskGroup.name ); taskGroupScale.update({ events: [] }); await TaskGroup.visit({ id: job.id, name: taskGroup.name }); assert.notOk(TaskGroup.hasScaleEvents); }); test('the recent scaling events section shows all recent scaling events in reverse chronological order', async function (assert) { const taskGroupScale = job.jobScale.taskGroupScales.models.find( (m) => m.name === taskGroup.name ); taskGroupScale.update({ events: [ server.create('scale-event', { error: true }), server.create('scale-event', { error: true }), server.create('scale-event', { error: true }), server.create('scale-event', { error: true }), server.create('scale-event', { count: 3, error: false }), server.create('scale-event', { count: 1, error: false }), ], }); const scaleEvents = taskGroupScale.events.models.sortBy('time').reverse(); await TaskGroup.visit({ id: job.id, name: taskGroup.name }); assert.ok(TaskGroup.hasScaleEvents); assert.notOk(TaskGroup.hasScalingTimeline); scaleEvents.forEach((scaleEvent, idx) => { const ScaleEvent = TaskGroup.scaleEvents[idx]; assert.equal( ScaleEvent.time, moment(scaleEvent.time / 1000000).format('MMM DD HH:mm:ss ZZ') ); assert.equal(ScaleEvent.message, scaleEvent.message); if (scaleEvent.count != null) { assert.equal(ScaleEvent.count, scaleEvent.count); } if (scaleEvent.error) { assert.ok(ScaleEvent.error); } if (Object.keys(scaleEvent.meta).length) { assert.ok(ScaleEvent.isToggleable); } else { assert.notOk(ScaleEvent.isToggleable); } }); }); test('when a task group has at least two count scaling events and the count scaling events outnumber the non-count scaling events, a timeline is shown in addition to the accordion', async function (assert) { const taskGroupScale = job.jobScale.taskGroupScales.models.find( (m) => m.name === taskGroup.name ); taskGroupScale.update({ events: [ server.create('scale-event', { error: true }), server.create('scale-event', { error: true }), server.create('scale-event', { count: 7, error: false }), server.create('scale-event', { count: 10, error: false }), server.create('scale-event', { count: 2, error: false }), server.create('scale-event', { count: 3, error: false }), server.create('scale-event', { count: 2, error: false }), server.create('scale-event', { count: 9, error: false }), server.create('scale-event', { count: 1, error: false }), ], }); const scaleEvents = taskGroupScale.events.models.sortBy('time').reverse(); await TaskGroup.visit({ id: job.id, name: taskGroup.name }); assert.ok(TaskGroup.hasScaleEvents); assert.ok(TaskGroup.hasScalingTimeline); assert.equal( TaskGroup.scalingAnnotations.length, scaleEvents.filter((ev) => ev.count == null).length ); }); testFacet('Status', { facet: TaskGroup.facets.status, paramName: 'status', expectedOptions: [ 'Pending', 'Running', 'Complete', 'Failed', 'Lost', 'Unknown', ], async beforeEach() { ['pending', 'running', 'complete', 'failed', 'lost', 'unknown'].forEach( (s) => { server.createList('allocation', 5, { clientStatus: s }); } ); await TaskGroup.visit({ id: job.id, name: taskGroup.name }); }, filter: (alloc, selection) => alloc.jobId == job.id && alloc.taskGroup == taskGroup.name && selection.includes(alloc.clientStatus), }); testFacet('Client', { facet: TaskGroup.facets.client, paramName: 'client', expectedOptions(allocs) { return Array.from( new Set( allocs .filter( (alloc) => alloc.jobId == job.id && alloc.taskGroup == taskGroup.name ) .mapBy('nodeId') .map((id) => id.split('-')[0]) ) ).sort(); }, async beforeEach() { const nodes = server.createList('node', 3, 'forceIPv4'); nodes.forEach((node) => server.createList('allocation', 5, { nodeId: node.id, jobId: job.id, taskGroup: taskGroup.name, }) ); await TaskGroup.visit({ id: job.id, name: taskGroup.name }); }, filter: (alloc, selection) => alloc.jobId == job.id && alloc.taskGroup == taskGroup.name && selection.includes(alloc.nodeId.split('-')[0]), }); }); function testFacet( label, { facet, paramName, beforeEach, filter, expectedOptions } ) { test(`facet ${label} | the ${label} facet has the correct options`, async function (assert) { await beforeEach(); await facet.toggle(); let expectation; if (typeof expectedOptions === 'function') { expectation = expectedOptions(server.db.allocations); } else { expectation = expectedOptions; } assert.deepEqual( facet.options.map((option) => option.label.trim()), expectation, 'Options for facet are as expected' ); }); test(`facet ${label} | the ${label} facet filters the allocations list by ${label}`, async function (assert) { let option; await beforeEach(); await facet.toggle(); option = facet.options.objectAt(0); await option.toggle(); const selection = [option.key]; const expectedAllocs = server.db.allocations .filter((alloc) => filter(alloc, selection)) .sortBy('modifyIndex') .reverse(); TaskGroup.allocations.forEach((alloc, index) => { assert.equal( alloc.id, expectedAllocs[index].id, `Allocation at ${index} is ${expectedAllocs[index].id}` ); }); }); test(`facet ${label} | selecting multiple options in the ${label} facet results in a broader search`, async function (assert) { const selection = []; await beforeEach(); await facet.toggle(); const option1 = facet.options.objectAt(0); const option2 = facet.options.objectAt(1); await option1.toggle(); selection.push(option1.key); await option2.toggle(); selection.push(option2.key); const expectedAllocs = server.db.allocations .filter((alloc) => filter(alloc, selection)) .sortBy('modifyIndex') .reverse(); TaskGroup.allocations.forEach((alloc, index) => { assert.equal( alloc.id, expectedAllocs[index].id, `Allocation at ${index} is ${expectedAllocs[index].id}` ); }); }); test(`facet ${label} | selecting options in the ${label} facet updates the ${paramName} query param`, async function (assert) { const selection = []; await beforeEach(); await facet.toggle(); const option1 = facet.options.objectAt(0); const option2 = facet.options.objectAt(1); await option1.toggle(); selection.push(option1.key); await option2.toggle(); selection.push(option2.key); assert.equal( currentURL(), `/jobs/${job.id}/${taskGroup.name}?${paramName}=${encodeURIComponent( JSON.stringify(selection) )}`, 'URL has the correct query param key and value' ); }); }