import Ember from 'ember'; import Response from 'ember-cli-mirage/response'; import { HOSTS } from './common'; import { logFrames, logEncode } from './data/logs'; import { generateDiff } from './factories/job-version'; import { generateTaskGroupFailures } from './factories/evaluation'; import { copy } from 'ember-copy'; export function findLeader(schema) { const agent = schema.agents.first(); return `${agent.address}:${agent.tags.port}`; } export function filesForPath(allocFiles, filterPath) { return allocFiles.where( file => (!filterPath || file.path.startsWith(filterPath)) && file.path.length > filterPath.length && !file.path.substr(filterPath.length + 1).includes('/') ); } export default function() { this.timing = 0; // delay for each request, automatically set to 0 during testing this.logging = window.location.search.includes('mirage-logging=true'); this.namespace = 'v1'; this.trackRequests = Ember.testing; const nomadIndices = {}; // used for tracking blocking queries const server = this; const withBlockingSupport = function(fn) { return function(schema, request) { // Get the original response let { url } = request; url = url.replace(/index=\d+[&;]?/, ''); const response = fn.apply(this, arguments); // Get and increment the appropriate index nomadIndices[url] || (nomadIndices[url] = 2); const index = nomadIndices[url]; nomadIndices[url]++; // Annotate the response with the index if (response instanceof Response) { response.headers['x-nomad-index'] = index; return response; } return new Response(200, { 'x-nomad-index': index }, response); }; }; this.get( '/jobs', withBlockingSupport(function({ jobs }, { queryParams }) { const json = this.serialize(jobs.all()); const namespace = queryParams.namespace || 'default'; return json .filter(job => { if (namespace === '*') return true; return namespace === 'default' ? !job.NamespaceID || job.NamespaceID === namespace : job.NamespaceID === namespace; }) .map(job => filterKeys(job, 'TaskGroups', 'NamespaceID')); }) ); this.post('/jobs', function(schema, req) { const body = JSON.parse(req.requestBody); if (!body.Job) return new Response(400, {}, 'Job is a required field on the request payload'); return okEmpty(); }); this.post('/jobs/parse', function(schema, req) { const body = JSON.parse(req.requestBody); if (!body.JobHCL) return new Response(400, {}, 'JobHCL is a required field on the request payload'); if (!body.Canonicalize) return new Response(400, {}, 'Expected Canonicalize to be true'); // Parse the name out of the first real line of HCL to match IDs in the new job record // Regex expectation: // in: job "job-name" { // out: job-name const nameFromHCLBlock = /.+?"(.+?)"/; const jobName = body.JobHCL.trim() .split('\n')[0] .match(nameFromHCLBlock)[1]; const job = server.create('job', { id: jobName }); return new Response(200, {}, this.serialize(job)); }); this.post('/job/:id/plan', function(schema, req) { const body = JSON.parse(req.requestBody); if (!body.Job) return new Response(400, {}, 'Job is a required field on the request payload'); if (!body.Diff) return new Response(400, {}, 'Expected Diff to be true'); const FailedTGAllocs = body.Job.Unschedulable && generateFailedTGAllocs(body.Job); return new Response( 200, {}, JSON.stringify({ FailedTGAllocs, Diff: generateDiff(req.params.id) }) ); }); this.get( '/job/:id', withBlockingSupport(function({ jobs }, { params, queryParams }) { const job = jobs.all().models.find(job => { const jobIsDefault = !job.namespaceId || job.namespaceId === 'default'; const qpIsDefault = !queryParams.namespace || queryParams.namespace === 'default'; return ( job.id === params.id && (job.namespaceId === queryParams.namespace || (jobIsDefault && qpIsDefault)) ); }); return job ? this.serialize(job) : new Response(404, {}, null); }) ); this.post('/job/:id', function(schema, req) { const body = JSON.parse(req.requestBody); if (!body.Job) return new Response(400, {}, 'Job is a required field on the request payload'); return okEmpty(); }); this.get( '/job/:id/summary', withBlockingSupport(function({ jobSummaries }, { params }) { return this.serialize(jobSummaries.findBy({ jobId: params.id })); }) ); this.get('/job/:id/allocations', function({ allocations }, { params }) { return this.serialize(allocations.where({ jobId: params.id })); }); this.get('/job/:id/versions', function({ jobVersions }, { params }) { return this.serialize(jobVersions.where({ jobId: params.id })); }); this.get('/job/:id/deployments', function({ deployments }, { params }) { return this.serialize(deployments.where({ jobId: params.id })); }); this.get('/job/:id/deployment', function({ deployments }, { params }) { const deployment = deployments.where({ jobId: params.id }).models[0]; return deployment ? this.serialize(deployment) : new Response(200, {}, 'null'); }); this.get( '/job/:id/scale', withBlockingSupport(function({ jobScales }, { params }) { const obj = jobScales.findBy({ jobId: params.id }); return this.serialize(jobScales.findBy({ jobId: params.id })); }) ); this.post('/job/:id/periodic/force', function(schema, { params }) { // Create the child job const parent = schema.jobs.find(params.id); // Use the server instead of the schema to leverage the job factory server.create('job', 'periodicChild', { parentId: parent.id, namespaceId: parent.namespaceId, namespace: parent.namespace, createAllocations: parent.createAllocations, }); return okEmpty(); }); this.post('/job/:id/revert', function({ jobs }, { requestBody }) { const { JobID, JobVersion } = JSON.parse(requestBody); const job = jobs.find(JobID); job.version = JobVersion; job.save(); return okEmpty(); }); this.post('/job/:id/scale', function({ jobs }, { params }) { return this.serialize(jobs.find(params.id)); }); this.delete('/job/:id', function(schema, { params }) { const job = schema.jobs.find(params.id); job.update({ status: 'dead' }); return new Response(204, {}, ''); }); this.get('/deployment/:id'); this.post('/deployment/fail/:id', function() { return new Response(204, {}, ''); }); this.post('/deployment/promote/:id', function() { return new Response(204, {}, ''); }); this.get('/job/:id/evaluations', function({ evaluations }, { params }) { return this.serialize(evaluations.where({ jobId: params.id })); }); this.get('/evaluation/:id'); this.get('/deployment/allocations/:id', function(schema, { params }) { const job = schema.jobs.find(schema.deployments.find(params.id).jobId); const allocations = schema.allocations.where({ jobId: job.id }); return this.serialize(allocations.slice(0, 3)); }); this.get('/nodes', function({ nodes }) { const json = this.serialize(nodes.all()); return json; }); this.get('/node/:id'); this.get('/node/:id/allocations', function({ allocations }, { params }) { return this.serialize(allocations.where({ nodeId: params.id })); }); this.post('/node/:id/eligibility', function({ nodes }, { params, requestBody }) { const body = JSON.parse(requestBody); const node = nodes.find(params.id); node.update({ schedulingEligibility: body.Elibility === 'eligible' }); return this.serialize(node); }); this.post('/node/:id/drain', function({ nodes }, { params }) { return this.serialize(nodes.find(params.id)); }); this.get('/allocations'); this.get('/allocation/:id'); this.post('/allocation/:id/stop', function() { return new Response(204, {}, ''); }); this.get( '/volumes', withBlockingSupport(function({ csiVolumes }, { queryParams }) { if (queryParams.type !== 'csi') { return new Response(200, {}, '[]'); } const json = this.serialize(csiVolumes.all()); const namespace = queryParams.namespace || 'default'; return json.filter(volume => { if (namespace === '*') return true; return namespace === 'default' ? !volume.NamespaceID || volume.NamespaceID === namespace : volume.NamespaceID === namespace; }); }) ); this.get( '/volume/:id', withBlockingSupport(function({ csiVolumes }, { params, queryParams }) { if (!params.id.startsWith('csi/')) { return new Response(404, {}, null); } const id = params.id.replace(/^csi\//, ''); const volume = csiVolumes.all().models.find(volume => { const volumeIsDefault = !volume.namespaceId || volume.namespaceId === 'default'; const qpIsDefault = !queryParams.namespace || queryParams.namespace === 'default'; return ( volume.id === id && (volume.namespaceId === queryParams.namespace || (volumeIsDefault && qpIsDefault)) ); }); return volume ? this.serialize(volume) : new Response(404, {}, null); }) ); this.get('/plugins', function({ csiPlugins }, { queryParams }) { if (queryParams.type !== 'csi') { return new Response(200, {}, '[]'); } return this.serialize(csiPlugins.all()); }); this.get('/plugin/:id', function({ csiPlugins }, { params }) { if (!params.id.startsWith('csi/')) { return new Response(404, {}, null); } const id = params.id.replace(/^csi\//, ''); const volume = csiPlugins.find(id); if (!volume) { return new Response(404, {}, null); } return this.serialize(volume); }); this.get('/namespaces', function({ namespaces }) { const records = namespaces.all(); if (records.length) { return this.serialize(records); } return this.serialize([{ Name: 'default' }]); }); this.get('/namespace/:id', function({ namespaces }, { params }) { return this.serialize(namespaces.find(params.id)); }); this.get('/agent/members', function({ agents, regions }) { const firstRegion = regions.first(); return { ServerRegion: firstRegion ? firstRegion.id : null, Members: this.serialize(agents.all()), }; }); this.get('/agent/self', function({ agents }) { return { member: this.serialize(agents.first()), }; }); this.get('/agent/monitor', function({ agents, nodes }, { queryParams }) { const serverId = queryParams.server_id; const clientId = queryParams.client_id; if (serverId && clientId) return new Response(400, {}, 'specify a client or a server, not both'); if (serverId && !agents.findBy({ name: serverId })) return new Response(400, {}, 'specified server does not exist'); if (clientId && !nodes.find(clientId)) return new Response(400, {}, 'specified client does not exist'); if (queryParams.plain) { return logFrames.join(''); } return logEncode(logFrames, logFrames.length - 1); }); this.get('/status/leader', function(schema) { return JSON.stringify(findLeader(schema)); }); this.get('/acl/token/self', function({ tokens }, req) { const secret = req.requestHeaders['X-Nomad-Token']; const tokenForSecret = tokens.findBy({ secretId: secret }); // Return the token if it exists if (tokenForSecret) { return this.serialize(tokenForSecret); } // Client error if it doesn't return new Response(400, {}, null); }); this.get('/acl/token/:id', function({ tokens }, req) { const token = tokens.find(req.params.id); const secret = req.requestHeaders['X-Nomad-Token']; const tokenForSecret = tokens.findBy({ secretId: secret }); // Return the token only if the request header matches the token // or the token is of type management if (token.secretId === secret || (tokenForSecret && tokenForSecret.type === 'management')) { return this.serialize(token); } // Return not authorized otherwise return new Response(403, {}, null); }); this.post('/acl/token/onetime/exchange', function({ tokens }, { requestBody }) { const { OneTimeSecretID } = JSON.parse(requestBody); const tokenForSecret = tokens.findBy({ oneTimeSecret: OneTimeSecretID }); // Return the token if it exists if (tokenForSecret) { return { Token: this.serialize(tokenForSecret), }; } // Forbidden error if it doesn't return new Response(403, {}, null); }); this.get('/acl/policy/:id', function({ policies, tokens }, req) { const policy = policies.find(req.params.id); const secret = req.requestHeaders['X-Nomad-Token']; const tokenForSecret = tokens.findBy({ secretId: secret }); if (req.params.id === 'anonymous') { if (policy) { return this.serialize(policy); } else { return new Response(404, {}, null); } } // Return the policy only if the token that matches the request header // includes the policy or if the token that matches the request header // is of type management if ( tokenForSecret && (tokenForSecret.policies.includes(policy) || tokenForSecret.type === 'management') ) { return this.serialize(policy); } // Return not authorized otherwise return new Response(403, {}, null); }); this.get('/regions', function({ regions }) { return this.serialize(regions.all()); }); this.get('/operator/license', function({ features }) { const records = features.all(); if (records.length) { return { License: { Features: records.models.mapBy('name'), }, }; } return new Response(501, {}, null); }); const clientAllocationStatsHandler = function({ clientAllocationStats }, { params }) { return this.serialize(clientAllocationStats.find(params.id)); }; const clientAllocationLog = function(server, { params, queryParams }) { const allocation = server.allocations.find(params.allocation_id); const tasks = allocation.taskStateIds.map(id => server.taskStates.find(id)); if (!tasks.mapBy('name').includes(queryParams.task)) { return new Response(400, {}, 'must include task name'); } if (queryParams.plain) { return logFrames.join(''); } return logEncode(logFrames, logFrames.length - 1); }; const clientAllocationFSLsHandler = function({ allocFiles }, { queryParams: { path } }) { const filterPath = path.endsWith('/') ? path.substr(0, path.length - 1) : path; const files = filesForPath(allocFiles, filterPath); return this.serialize(files); }; const clientAllocationFSStatHandler = function({ allocFiles }, { queryParams: { path } }) { const filterPath = path.endsWith('/') ? path.substr(0, path.length - 1) : path; // Root path if (!filterPath) { return this.serialize({ IsDir: true, ModTime: new Date(), }); } // Either a file or a nested directory const file = allocFiles.where({ path: filterPath }).models[0]; return this.serialize(file); }; const clientAllocationCatHandler = function({ allocFiles }, { queryParams }) { const [file, err] = fileOrError(allocFiles, queryParams.path); if (err) return err; return file.body; }; const clientAllocationStreamHandler = function({ allocFiles }, { queryParams }) { const [file, err] = fileOrError(allocFiles, queryParams.path); if (err) return err; // Pretender, and therefore Mirage, doesn't support streaming responses. return file.body; }; const clientAllocationReadAtHandler = function({ allocFiles }, { queryParams }) { const [file, err] = fileOrError(allocFiles, queryParams.path); if (err) return err; return file.body.substr(queryParams.offset || 0, queryParams.limit); }; const fileOrError = function(allocFiles, path, message = 'Operation not allowed on a directory') { // Root path if (path === '/') { return [null, new Response(400, {}, message)]; } const file = allocFiles.where({ path }).models[0]; if (file.isDir) { return [null, new Response(400, {}, message)]; } return [file, null]; }; // Client requests are available on the server and the client this.put('/client/allocation/:id/restart', function() { return new Response(204, {}, ''); }); this.get('/client/allocation/:id/stats', clientAllocationStatsHandler); this.get('/client/fs/logs/:allocation_id', clientAllocationLog); this.get('/client/fs/ls/:allocation_id', clientAllocationFSLsHandler); this.get('/client/fs/stat/:allocation_id', clientAllocationFSStatHandler); this.get('/client/fs/cat/:allocation_id', clientAllocationCatHandler); this.get('/client/fs/stream/:allocation_id', clientAllocationStreamHandler); this.get('/client/fs/readat/:allocation_id', clientAllocationReadAtHandler); this.get('/client/stats', function({ clientStats }, { queryParams }) { const seed = faker.random.number(10); if (seed >= 8) { const stats = clientStats.find(queryParams.node_id); stats.update({ timestamp: Date.now() * 1000000, CPUTicksConsumed: stats.CPUTicksConsumed + faker.random.number({ min: -10, max: 10 }), }); return this.serialize(stats); } else { return new Response(500, {}, null); } }); // TODO: in the future, this hack may be replaceable with dynamic host name // support in pretender: https://github.com/pretenderjs/pretender/issues/210 HOSTS.forEach(host => { this.get(`http://${host}/v1/client/allocation/:id/stats`, clientAllocationStatsHandler); this.get(`http://${host}/v1/client/fs/logs/:allocation_id`, clientAllocationLog); this.get(`http://${host}/v1/client/fs/ls/:allocation_id`, clientAllocationFSLsHandler); this.get(`http://${host}/v1/client/stat/ls/:allocation_id`, clientAllocationFSStatHandler); this.get(`http://${host}/v1/client/fs/cat/:allocation_id`, clientAllocationCatHandler); this.get(`http://${host}/v1/client/fs/stream/:allocation_id`, clientAllocationStreamHandler); this.get(`http://${host}/v1/client/fs/readat/:allocation_id`, clientAllocationReadAtHandler); this.get(`http://${host}/v1/client/stats`, function({ clientStats }) { return this.serialize(clientStats.find(host)); }); }); this.post('/search/fuzzy', function( { allocations, jobs, nodes, taskGroups, csiPlugins }, { requestBody }) { const { Text } = JSON.parse(requestBody); const matchedAllocs = allocations.where(allocation => allocation.name.includes(Text)); const matchedGroups = taskGroups.where(taskGroup => taskGroup.name.includes(Text)); const matchedJobs = jobs.where(job => job.name.includes(Text)); const matchedNodes = nodes.where(node => node.name.includes(Text)); const matchedPlugins = csiPlugins.where(plugin => plugin.id.includes(Text)); const transformedAllocs = matchedAllocs.models.map(alloc => ({ ID: alloc.name, Scope: [ (alloc.namespace || {}).id, alloc.id, ], })); const transformedGroups = matchedGroups.models.map(group => ({ ID: group.name, Scope: [ group.job.namespace, group.job.id, ], })); const transformedJobs = matchedJobs.models.map(job => ({ ID: job.name, Scope: [ job.namespace, job.id, ] })); const transformedNodes = matchedNodes.models.map(node => ({ ID: node.name, Scope: [ node.id, ], })); const transformedPlugins = matchedPlugins.models.map(plugin => ({ ID: plugin.id, })); const truncatedAllocs = transformedAllocs.slice(0, 20); const truncatedGroups = transformedGroups.slice(0, 20); const truncatedJobs = transformedJobs.slice(0, 20); const truncatedNodes = transformedNodes.slice(0, 20); const truncatedPlugins = transformedPlugins.slice(0, 20); return { Matches: { allocs: truncatedAllocs, groups: truncatedGroups, jobs: truncatedJobs, nodes: truncatedNodes, plugins: truncatedPlugins, }, Truncations: { allocs: truncatedAllocs.length < truncatedAllocs.length, groups: truncatedGroups.length < transformedGroups.length, jobs: truncatedJobs.length < transformedJobs.length, nodes: truncatedNodes.length < transformedNodes.length, plugins: truncatedPlugins.length < transformedPlugins.length, }, } }); this.get('/recommendations', function( { jobs, namespaces, recommendations }, { queryParams: { job: id, namespace } } ) { if (id) { if (!namespaces.all().length) { namespace = null; } const job = jobs.findBy({ id, namespace }); if (!job) { return []; } const taskGroups = job.taskGroups.models; const tasks = taskGroups.reduce((tasks, taskGroup) => { return tasks.concat(taskGroup.tasks.models); }, []); const recommendationIds = tasks.reduce((recommendationIds, task) => { return recommendationIds.concat(task.recommendations.models.mapBy('id')); }, []); return recommendations.find(recommendationIds); } else { return recommendations.all(); } }); this.post('/recommendations/apply', function({ recommendations }, { requestBody }) { const { Apply, Dismiss } = JSON.parse(requestBody); Apply.concat(Dismiss).forEach(id => { const recommendation = recommendations.find(id); const task = recommendation.task; if (Apply.includes(id)) { task.resources[recommendation.resource] = recommendation.value; } recommendation.destroy(); task.save(); }); return {}; }); } function filterKeys(object, ...keys) { const clone = copy(object, true); keys.forEach(key => { delete clone[key]; }); return clone; } // An empty response but not a 204 No Content. This is still a valid JSON // response that represents a payload with no worthwhile data. function okEmpty() { return new Response(200, {}, '{}'); } function generateFailedTGAllocs(job, taskGroups) { const taskGroupsFromSpec = job.TaskGroups && job.TaskGroups.mapBy('Name'); let tgNames = ['tg-one', 'tg-two']; if (taskGroupsFromSpec && taskGroupsFromSpec.length) tgNames = taskGroupsFromSpec; if (taskGroups && taskGroups.length) tgNames = taskGroups; return tgNames.reduce((hash, tgName) => { hash[tgName] = generateTaskGroupFailures(); return hash; }, {}); }