/** * Copyright (c) HashiCorp, Inc. * SPDX-License-Identifier: MPL-2.0 */ 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'; import faker from 'nomad-ui/mirage/faker'; 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.get('/job/:id/submission', function (schema, req) { return new Response( 200, {}, JSON.stringify({ Source: `job "${req.params.id}" {`, Format: 'hcl2', VariableFlags: { X: 'x', Y: '42', Z: 'true' }, Variables: 'var file content', }) ); }); 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); const jobPlanWarnings = body.Job.WithWarnings && generateWarnings(); return new Response( 200, {}, JSON.stringify({ FailedTGAllocs, Warnings: jobPlanWarnings, 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.policyIds || []; const roleIds = token.roleIds || []; const roles = server.db.roles.find(roleIds); const rolePolicyIds = roles.map((role) => role.policyIds).flat(); const policies = server.db.policies.find([ ...policyIds, ...rolePolicyIds, ]); 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('/node/pools', function ({ nodePools }) { return this.serialize(nodePools.all()); }); 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/tokens', function ({ tokens }, req) { return this.serialize(tokens.all()); }); this.delete('/acl/token/:id', function (schema, request) { const { id } = request.params; server.db.tokens.remove(id); return ''; }); this.post('/acl/token', function (schema, request) { const { Name, Policies, Type, ExpirationTTL, ExpirationTime } = JSON.parse( request.requestBody ); function parseDuration(duration) { const [_, value, unit] = duration.match(/(\d+)(\w)/); const unitMap = { s: 1000, m: 1000 * 60, h: 1000 * 60 * 60, d: 1000 * 60 * 60 * 24, }; return value * unitMap[unit]; } // If there's an expirationTime, use that. Otherwise, use the TTL. const expirationTime = ExpirationTime ? new Date(ExpirationTime) : ExpirationTTL ? new Date(Date.now() + parseDuration(ExpirationTTL)) : null; return server.create('token', { name: Name, policyIds: Policies, type: Type, id: faker.random.uuid(), expirationTime, createTime: new Date().toISOString(), }); }); this.post('/acl/token/:id', function (schema, request) { // If both Policies and Roles arrays are empty, return an error const { Policies, Roles } = JSON.parse(request.requestBody); if (!Policies.length && !Roles.length) { return new Response( 500, {}, 'Either Policies or Roles must be specified' ); } return new Response( 200, {}, { id: request.params.id, Policies, Roles, } ); }); 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.post('/acl/login', function (schema, { requestBody }) { const { LoginToken } = JSON.parse(requestBody); const tokenType = LoginToken.endsWith('management') ? 'management' : 'client'; const isBad = LoginToken.endsWith('bad'); if (isBad) { return new Response(403, {}, null); } else { const token = schema.tokens .all() .models.find((token) => token.type === tokenType); return this.serialize(token); } }); 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.findBy({ name: 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.roles.models.any((role) => role.policies.includes(policy) ) || tokenForSecret.type === 'management') ) { return this.serialize(policy); } // Return not authorized otherwise return new Response(403, {}, null); }); this.get('/acl/roles', function ({ roles }, req) { return this.serialize(roles.all()); }); this.get('/acl/role/:id', function ({ roles }, req) { const role = roles.findBy({ id: req.params.id }); return this.serialize(role); }); this.post('/acl/role', function (schema, request) { const { Name, Description } = JSON.parse(request.requestBody); return server.create('role', { name: Name, description: Description, }); }); this.put('/acl/role/:id', function (schema, request) { const { Policies } = JSON.parse(request.requestBody); if (!Policies.length) { return new Response(500, {}, 'Policies must be specified'); } return new Response( 200, {}, { id: request.params.id, Policies, } ); }); this.delete('/acl/role/:id', function (schema, request) { const { id } = request.params; // Also update any tokens whose policyIDs include this policy const tokens = server.schema.tokens.where((token) => token.roleIds?.includes(id)) || []; tokens.models.forEach((token) => { token.update({ roleIds: token.roleIds.filter((roleId) => roleId !== id), }); }); server.db.roles.remove(id); return ''; }); this.get('/acl/policies', function ({ policies }, req) { return this.serialize(policies.all()); }); this.delete('/acl/policy/:id', function (schema, request) { const { id } = request.params; // Also update any tokens whose policyIDs include this policy const tokens = server.schema.tokens.where((token) => token.policyIds?.includes(id)) || []; tokens.models.forEach((token) => { token.update({ policyIds: token.policyIds.filter((policyId) => policyId !== id), }); }); // Also update any roles whose policyIDs include this policy const roles = server.schema.roles.where((role) => role.policyIds?.includes(id)) || []; roles.models.forEach((role) => { role.update({ policyIds: role.policyIds.filter((policyId) => policyId !== id), }); }); server.db.policies.remove(id); return ''; }); this.put('/acl/policy/:id', function (schema, request) { return new Response(200, {}, {}); }); this.post('/acl/policy/:id', function (schema, request) { const { Name, Description, Rules } = JSON.parse(request.requestBody); return server.create('policy', { name: Name, description: Description, rules: Rules, }); }); 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); } }); // Metadata this.post( '/client/metadata', function (schema, { queryParams: { node_id }, requestBody }) { const attrs = JSON.parse(requestBody); const node = schema.nodes.find(node_id); Object.entries(attrs.Meta).forEach(([key, value]) => { if (value === null) { delete node.meta[key]; delete attrs.Meta[key]; } }); return { Meta: { ...node.meta, ...attrs.Meta } }; } ); // 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 Variables this.get('/vars', function (schema, { queryParams: { namespace, prefix } }) { if (prefix === 'nomad/job-templates') { return schema.variables .all() .filter((v) => v.path.includes('nomad/job-templates')); } if (namespace && namespace !== '*') { return schema.variables.all().filter((v) => v.namespace === namespace); } else { return schema.variables.all(); } }); this.get('/var/:id', function ({ variables }, { params }) { let variable = variables.find(params.id); if (!variable) { return new Response(404, {}, {}); } return variable; }); this.put('/var/:id', function (schema, request) { const { Path, Namespace, Items } = JSON.parse(request.requestBody); if (request.url.includes('cas=') && Path === 'Auto-conflicting Variable') { return new Response( 409, {}, { CreateIndex: 65, CreateTime: faker.date.recent(14) * 1000000, // in the past couple weeks Items: { edited_by: 'your_remote_pal' }, ModifyIndex: 2118, ModifyTime: faker.date.recent(0.01) * 1000000, // a few minutes ago Namespace: Namespace, Path: Path, } ); } else { return server.create('variable', { path: Path, namespace: Namespace, items: Items, id: Path, }); } }); this.delete('/var/:id', function (schema, request) { const { id } = request.params; server.db.variables.remove(id); return ''; }); //#endregion Variables //#region Services const allocationServiceChecksHandler = function (schema) { let disasters = [ "Moon's haunted", 'reticulating splines', 'The operation completed unexpectedly', 'Ran out of sriracha :(', '¯\\_(ツ)_/¯', '\n\n \n \n Error response\n \n \n

Error response

\n

Error code: 404

\n

Message: File not found.

\n

Error code explanation: HTTPStatus.NOT_FOUND - Nothing matches the given URI.

\n \n\n', ]; let fakeChecks = []; schema.serviceFragments.all().models.forEach((frag, iter) => { [...Array(iter)].forEach((check, checkIter) => { const checkOK = faker.random.boolean(); fakeChecks.push({ Check: `check-${checkIter}`, Group: `job-name.${frag.taskGroup?.name}[1]`, Output: checkOK ? 'nomad: http ok' : disasters[Math.floor(Math.random() * disasters.length)], Service: frag.name, Status: checkOK ? 'success' : 'failure', StatusCode: checkOK ? 200 : 400, Task: frag.task?.name, Timestamp: new Date().getTime(), }); }); }); return fakeChecks; }; this.get('/job/:id/services', function (schema, { params }) { const { services } = schema; return this.serialize(services.where({ jobId: params.id })); }); this.get('/client/allocation/:id/checks', allocationServiceChecksHandler); //#endregion Services //#region SSO this.get('/acl/auth-methods', function (schema, request) { return schema.authMethods.all(); }); this.post('/acl/oidc/auth-url', (schema, req) => { const { AuthMethodName, ClientNonce, RedirectUri, Meta } = JSON.parse( req.requestBody ); return new Response( 200, {}, { AuthURL: `/ui/oidc-mock?auth_method=${AuthMethodName}&client_nonce=${ClientNonce}&redirect_uri=${RedirectUri}&meta=${Meta}`, } ); }); // Simulate an OIDC callback by assuming the code passed is the secret of an existing token, and return that token. this.post( '/acl/oidc/complete-auth', function (schema, req) { const code = JSON.parse(req.requestBody).Code; const token = schema.tokens.findBy({ id: code, }); return new Response( 200, {}, { SecretID: token.secretId, } ); }, { timing: 1000 } ); //#endregion SSO } 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; }, {}); } function generateWarnings() { return '2 warnings:\n\n* Group "yourtask" has warnings: 1 error occurred:\n\t* Task "yourtask" has warnings: 1 error occurred:\n\t* 2 errors occurred:\n\t* Identity[vault_default] identities without an audience are insecure\n\t* Identity[vault_default] identities without an expiration are insecure\n* Task yourtask has an identity called vault_default but no vault block'; }