449 lines
14 KiB
JavaScript
449 lines
14 KiB
JavaScript
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 default function() {
|
|
this.timing = 0; // delay for each request, automatically set to 0 during testing
|
|
|
|
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 =>
|
|
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.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.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/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.get('/allocations');
|
|
|
|
this.get('/allocation/:id');
|
|
|
|
this.post('/allocation/:id/stop', function() {
|
|
return new Response(204, {}, '');
|
|
});
|
|
|
|
this.get('/namespaces', function({ namespaces }) {
|
|
const records = namespaces.all();
|
|
|
|
if (records.length) {
|
|
return this.serialize(records);
|
|
}
|
|
|
|
return new Response(501, {}, null);
|
|
});
|
|
|
|
this.get('/namespace/:id', function({ namespaces }, { params }) {
|
|
if (namespaces.all().length) {
|
|
return this.serialize(namespaces.find(params.id));
|
|
}
|
|
|
|
return new Response(501, {}, null);
|
|
});
|
|
|
|
this.get('/agent/members', function({ agents, regions }) {
|
|
const firstRegion = regions.first();
|
|
return {
|
|
ServerRegion: firstRegion ? firstRegion.id : null,
|
|
Members: this.serialize(agents.all()),
|
|
};
|
|
});
|
|
|
|
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.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 });
|
|
|
|
// 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());
|
|
});
|
|
|
|
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 }) {
|
|
// Ignore the task name at the beginning of the path
|
|
const filterPath = queryParams.path.substr(queryParams.path.indexOf('/') + 1);
|
|
|
|
const files = allocFiles.where(
|
|
file =>
|
|
(!filterPath || file.path.startsWith(filterPath)) &&
|
|
file.path.length > filterPath.length &&
|
|
!file.path.substr(filterPath.length + 1).includes('/')
|
|
);
|
|
|
|
return this.serialize(files);
|
|
};
|
|
|
|
const clientAllocationFSStatHandler = function({ allocFiles }, { queryParams }) {
|
|
// Ignore the task name at the beginning of the path
|
|
const filterPath = queryParams.path.substr(queryParams.path.indexOf('/') + 1);
|
|
|
|
// 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') {
|
|
// Ignore the task name at the beginning of the path
|
|
const filterPath = path.substr(path.indexOf('/') + 1);
|
|
|
|
// Root path
|
|
if (!filterPath) {
|
|
return [null, new Response(400, {}, message)];
|
|
}
|
|
|
|
const file = allocFiles.where({ path: filterPath }).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 = Math.random();
|
|
if (seed > 0.8) {
|
|
const stats = clientStats.find(queryParams.node_id);
|
|
stats.update({
|
|
timestamp: Date.now() * 1000000,
|
|
CPUTicksConsumed: stats.CPUTicksConsumed + (Math.random() * 20 - 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/stats`, function({ clientStats }) {
|
|
return this.serialize(clientStats.find(host));
|
|
});
|
|
});
|
|
}
|
|
|
|
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;
|
|
}, {});
|
|
}
|