open-nomad/ui/mirage/config.js
Phil Renaud 7de6301054 Throw an error if you try to save a Secure Variable with no items (#13424)
* Error thrown if you have no KVs on save

* Acceptance tests for flash messages and no-key-value adds

* Post-hoc accounting for new variable path routing

* Trim on key before validating it as existing
2022-07-11 13:34:06 -04:00

897 lines
25 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';
import formatHost from 'nomad-ui/utils/format-host';
export function findLeader(schema) {
const agent = schema.agents.first();
return formatHost(agent.member.Address, agent.member.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/dispatch', 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
let dispatched = server.create('job', 'parameterizedChild', {
parentId: parent.id,
namespaceId: parent.namespaceId,
namespace: parent.namespace,
createAllocations: parent.createAllocations,
});
return new Response(
200,
{},
JSON.stringify({
DispatchedJobID: dispatched.id,
})
);
});
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('/evaluations');
this.get('/evaluation/:id', function ({ evaluations }, { params }) {
return evaluations.find(params.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 }, req) {
// authorize user permissions
const token = server.db.tokens.findBy({
secretId: req.requestHeaders['X-Nomad-Token'],
});
if (token) {
const { policyIds } = token;
const policies = server.db.policies.find(policyIds);
const hasReadPolicy = policies.find(
(p) =>
p.rulesJSON.Node?.Policy === 'read' ||
p.rulesJSON.Node?.Policy === 'write'
);
if (hasReadPolicy) {
const json = this.serialize(nodes.all());
return json;
}
return new Response(403, {}, 'Permissions have not be set-up.');
}
// TODO: Think about policy handling in Mirage set-up
return this.serialize(nodes.all());
});
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()).map(({ member }) => ({
...member,
})),
};
});
this.get('/agent/self', function ({ agents }) {
return 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 || 'default', 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 || 'default', 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 {};
}
);
//#region Secure Variables
this.get('/vars', function (schema) {
return schema.variables.all();
});
this.get('/var/:id', function ({ variables }, { params }) {
return variables.find(params.id);
});
this.put('/var/:id', function (schema, request) {
const { Path, Namespace, Items } = JSON.parse(request.requestBody);
return server.create('variable', {
Path,
Namespace,
Items,
id: Path,
});
});
this.delete('/var/:id', function (schema, request) {
const { id } = request.params;
server.db.variables.remove(id);
return okEmpty();
});
//#endregion Secure Variables
}
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;
}, {});
}