github.com/Ilhicas/nomad@v1.0.4-0.20210304152020-e86851182bc3/ui/mirage/config.js (about) 1 import Ember from 'ember'; 2 import Response from 'ember-cli-mirage/response'; 3 import { HOSTS } from './common'; 4 import { logFrames, logEncode } from './data/logs'; 5 import { generateDiff } from './factories/job-version'; 6 import { generateTaskGroupFailures } from './factories/evaluation'; 7 import { copy } from 'ember-copy'; 8 9 export function findLeader(schema) { 10 const agent = schema.agents.first(); 11 return `${agent.address}:${agent.tags.port}`; 12 } 13 14 export function filesForPath(allocFiles, filterPath) { 15 return allocFiles.where( 16 file => 17 (!filterPath || file.path.startsWith(filterPath)) && 18 file.path.length > filterPath.length && 19 !file.path.substr(filterPath.length + 1).includes('/') 20 ); 21 } 22 23 export default function() { 24 this.timing = 0; // delay for each request, automatically set to 0 during testing 25 26 this.logging = window.location.search.includes('mirage-logging=true'); 27 28 this.namespace = 'v1'; 29 this.trackRequests = Ember.testing; 30 31 const nomadIndices = {}; // used for tracking blocking queries 32 const server = this; 33 const withBlockingSupport = function(fn) { 34 return function(schema, request) { 35 // Get the original response 36 let { url } = request; 37 url = url.replace(/index=\d+[&;]?/, ''); 38 const response = fn.apply(this, arguments); 39 40 // Get and increment the appropriate index 41 nomadIndices[url] || (nomadIndices[url] = 2); 42 const index = nomadIndices[url]; 43 nomadIndices[url]++; 44 45 // Annotate the response with the index 46 if (response instanceof Response) { 47 response.headers['x-nomad-index'] = index; 48 return response; 49 } 50 return new Response(200, { 'x-nomad-index': index }, response); 51 }; 52 }; 53 54 this.get( 55 '/jobs', 56 withBlockingSupport(function({ jobs }, { queryParams }) { 57 const json = this.serialize(jobs.all()); 58 const namespace = queryParams.namespace || 'default'; 59 return json 60 .filter(job => 61 namespace === 'default' 62 ? !job.NamespaceID || job.NamespaceID === namespace 63 : job.NamespaceID === namespace 64 ) 65 .map(job => filterKeys(job, 'TaskGroups', 'NamespaceID')); 66 }) 67 ); 68 69 this.post('/jobs', function(schema, req) { 70 const body = JSON.parse(req.requestBody); 71 72 if (!body.Job) return new Response(400, {}, 'Job is a required field on the request payload'); 73 74 return okEmpty(); 75 }); 76 77 this.post('/jobs/parse', function(schema, req) { 78 const body = JSON.parse(req.requestBody); 79 80 if (!body.JobHCL) 81 return new Response(400, {}, 'JobHCL is a required field on the request payload'); 82 if (!body.Canonicalize) return new Response(400, {}, 'Expected Canonicalize to be true'); 83 84 // Parse the name out of the first real line of HCL to match IDs in the new job record 85 // Regex expectation: 86 // in: job "job-name" { 87 // out: job-name 88 const nameFromHCLBlock = /.+?"(.+?)"/; 89 const jobName = body.JobHCL.trim() 90 .split('\n')[0] 91 .match(nameFromHCLBlock)[1]; 92 93 const job = server.create('job', { id: jobName }); 94 return new Response(200, {}, this.serialize(job)); 95 }); 96 97 this.post('/job/:id/plan', function(schema, req) { 98 const body = JSON.parse(req.requestBody); 99 100 if (!body.Job) return new Response(400, {}, 'Job is a required field on the request payload'); 101 if (!body.Diff) return new Response(400, {}, 'Expected Diff to be true'); 102 103 const FailedTGAllocs = body.Job.Unschedulable && generateFailedTGAllocs(body.Job); 104 105 return new Response( 106 200, 107 {}, 108 JSON.stringify({ FailedTGAllocs, Diff: generateDiff(req.params.id) }) 109 ); 110 }); 111 112 this.get( 113 '/job/:id', 114 withBlockingSupport(function({ jobs }, { params, queryParams }) { 115 const job = jobs.all().models.find(job => { 116 const jobIsDefault = !job.namespaceId || job.namespaceId === 'default'; 117 const qpIsDefault = !queryParams.namespace || queryParams.namespace === 'default'; 118 return ( 119 job.id === params.id && 120 (job.namespaceId === queryParams.namespace || (jobIsDefault && qpIsDefault)) 121 ); 122 }); 123 124 return job ? this.serialize(job) : new Response(404, {}, null); 125 }) 126 ); 127 128 this.post('/job/:id', function(schema, req) { 129 const body = JSON.parse(req.requestBody); 130 131 if (!body.Job) return new Response(400, {}, 'Job is a required field on the request payload'); 132 133 return okEmpty(); 134 }); 135 136 this.get( 137 '/job/:id/summary', 138 withBlockingSupport(function({ jobSummaries }, { params }) { 139 return this.serialize(jobSummaries.findBy({ jobId: params.id })); 140 }) 141 ); 142 143 this.get('/job/:id/allocations', function({ allocations }, { params }) { 144 return this.serialize(allocations.where({ jobId: params.id })); 145 }); 146 147 this.get('/job/:id/versions', function({ jobVersions }, { params }) { 148 return this.serialize(jobVersions.where({ jobId: params.id })); 149 }); 150 151 this.get('/job/:id/deployments', function({ deployments }, { params }) { 152 return this.serialize(deployments.where({ jobId: params.id })); 153 }); 154 155 this.get('/job/:id/deployment', function({ deployments }, { params }) { 156 const deployment = deployments.where({ jobId: params.id }).models[0]; 157 return deployment ? this.serialize(deployment) : new Response(200, {}, 'null'); 158 }); 159 160 this.get( 161 '/job/:id/scale', 162 withBlockingSupport(function({ jobScales }, { params }) { 163 const obj = jobScales.findBy({ jobId: params.id }); 164 return this.serialize(jobScales.findBy({ jobId: params.id })); 165 }) 166 ); 167 168 this.post('/job/:id/periodic/force', function(schema, { params }) { 169 // Create the child job 170 const parent = schema.jobs.find(params.id); 171 172 // Use the server instead of the schema to leverage the job factory 173 server.create('job', 'periodicChild', { 174 parentId: parent.id, 175 namespaceId: parent.namespaceId, 176 namespace: parent.namespace, 177 createAllocations: parent.createAllocations, 178 }); 179 180 return okEmpty(); 181 }); 182 183 this.post('/job/:id/scale', function({ jobs }, { params }) { 184 return this.serialize(jobs.find(params.id)); 185 }); 186 187 this.delete('/job/:id', function(schema, { params }) { 188 const job = schema.jobs.find(params.id); 189 job.update({ status: 'dead' }); 190 return new Response(204, {}, ''); 191 }); 192 193 this.get('/deployment/:id'); 194 195 this.post('/deployment/fail/:id', function() { 196 return new Response(204, {}, ''); 197 }); 198 199 this.post('/deployment/promote/:id', function() { 200 return new Response(204, {}, ''); 201 }); 202 203 this.get('/job/:id/evaluations', function({ evaluations }, { params }) { 204 return this.serialize(evaluations.where({ jobId: params.id })); 205 }); 206 207 this.get('/evaluation/:id'); 208 209 this.get('/deployment/allocations/:id', function(schema, { params }) { 210 const job = schema.jobs.find(schema.deployments.find(params.id).jobId); 211 const allocations = schema.allocations.where({ jobId: job.id }); 212 213 return this.serialize(allocations.slice(0, 3)); 214 }); 215 216 this.get('/nodes', function({ nodes }) { 217 const json = this.serialize(nodes.all()); 218 return json; 219 }); 220 221 this.get('/node/:id'); 222 223 this.get('/node/:id/allocations', function({ allocations }, { params }) { 224 return this.serialize(allocations.where({ nodeId: params.id })); 225 }); 226 227 this.post('/node/:id/eligibility', function({ nodes }, { params, requestBody }) { 228 const body = JSON.parse(requestBody); 229 const node = nodes.find(params.id); 230 231 node.update({ schedulingEligibility: body.Elibility === 'eligible' }); 232 return this.serialize(node); 233 }); 234 235 this.post('/node/:id/drain', function({ nodes }, { params }) { 236 return this.serialize(nodes.find(params.id)); 237 }); 238 239 this.get('/allocations'); 240 241 this.get('/allocation/:id'); 242 243 this.post('/allocation/:id/stop', function() { 244 return new Response(204, {}, ''); 245 }); 246 247 this.get( 248 '/volumes', 249 withBlockingSupport(function({ csiVolumes }, { queryParams }) { 250 if (queryParams.type !== 'csi') { 251 return new Response(200, {}, '[]'); 252 } 253 254 const json = this.serialize(csiVolumes.all()); 255 const namespace = queryParams.namespace || 'default'; 256 return json.filter(volume => 257 namespace === 'default' 258 ? !volume.NamespaceID || volume.NamespaceID === namespace 259 : volume.NamespaceID === namespace 260 ); 261 }) 262 ); 263 264 this.get( 265 '/volume/:id', 266 withBlockingSupport(function({ csiVolumes }, { params }) { 267 if (!params.id.startsWith('csi/')) { 268 return new Response(404, {}, null); 269 } 270 271 const id = params.id.replace(/^csi\//, ''); 272 const volume = csiVolumes.find(id); 273 274 if (!volume) { 275 return new Response(404, {}, null); 276 } 277 278 return this.serialize(volume); 279 }) 280 ); 281 282 this.get('/plugins', function({ csiPlugins }, { queryParams }) { 283 if (queryParams.type !== 'csi') { 284 return new Response(200, {}, '[]'); 285 } 286 287 return this.serialize(csiPlugins.all()); 288 }); 289 290 this.get('/plugin/:id', function({ csiPlugins }, { params }) { 291 if (!params.id.startsWith('csi/')) { 292 return new Response(404, {}, null); 293 } 294 295 const id = params.id.replace(/^csi\//, ''); 296 const volume = csiPlugins.find(id); 297 298 if (!volume) { 299 return new Response(404, {}, null); 300 } 301 302 return this.serialize(volume); 303 }); 304 305 this.get('/namespaces', function({ namespaces }) { 306 const records = namespaces.all(); 307 308 if (records.length) { 309 return this.serialize(records); 310 } 311 312 return new Response(501, {}, null); 313 }); 314 315 this.get('/namespace/:id', function({ namespaces }, { params }) { 316 if (namespaces.all().length) { 317 return this.serialize(namespaces.find(params.id)); 318 } 319 320 return new Response(501, {}, null); 321 }); 322 323 this.get('/agent/members', function({ agents, regions }) { 324 const firstRegion = regions.first(); 325 return { 326 ServerRegion: firstRegion ? firstRegion.id : null, 327 Members: this.serialize(agents.all()), 328 }; 329 }); 330 331 this.get('/agent/self', function({ agents }) { 332 return { 333 member: this.serialize(agents.first()), 334 }; 335 }); 336 337 this.get('/agent/monitor', function({ agents, nodes }, { queryParams }) { 338 const serverId = queryParams.server_id; 339 const clientId = queryParams.client_id; 340 341 if (serverId && clientId) 342 return new Response(400, {}, 'specify a client or a server, not both'); 343 if (serverId && !agents.findBy({ name: serverId })) 344 return new Response(400, {}, 'specified server does not exist'); 345 if (clientId && !nodes.find(clientId)) 346 return new Response(400, {}, 'specified client does not exist'); 347 348 if (queryParams.plain) { 349 return logFrames.join(''); 350 } 351 352 return logEncode(logFrames, logFrames.length - 1); 353 }); 354 355 this.get('/status/leader', function(schema) { 356 return JSON.stringify(findLeader(schema)); 357 }); 358 359 this.get('/acl/token/self', function({ tokens }, req) { 360 const secret = req.requestHeaders['X-Nomad-Token']; 361 const tokenForSecret = tokens.findBy({ secretId: secret }); 362 363 // Return the token if it exists 364 if (tokenForSecret) { 365 return this.serialize(tokenForSecret); 366 } 367 368 // Client error if it doesn't 369 return new Response(400, {}, null); 370 }); 371 372 this.get('/acl/token/:id', function({ tokens }, req) { 373 const token = tokens.find(req.params.id); 374 const secret = req.requestHeaders['X-Nomad-Token']; 375 const tokenForSecret = tokens.findBy({ secretId: secret }); 376 377 // Return the token only if the request header matches the token 378 // or the token is of type management 379 if (token.secretId === secret || (tokenForSecret && tokenForSecret.type === 'management')) { 380 return this.serialize(token); 381 } 382 383 // Return not authorized otherwise 384 return new Response(403, {}, null); 385 }); 386 387 this.get('/acl/policy/:id', function({ policies, tokens }, req) { 388 const policy = policies.find(req.params.id); 389 const secret = req.requestHeaders['X-Nomad-Token']; 390 const tokenForSecret = tokens.findBy({ secretId: secret }); 391 392 if (req.params.id === 'anonymous') { 393 if (policy) { 394 return this.serialize(policy); 395 } else { 396 return new Response(404, {}, null); 397 } 398 } 399 400 // Return the policy only if the token that matches the request header 401 // includes the policy or if the token that matches the request header 402 // is of type management 403 if ( 404 tokenForSecret && 405 (tokenForSecret.policies.includes(policy) || tokenForSecret.type === 'management') 406 ) { 407 return this.serialize(policy); 408 } 409 410 // Return not authorized otherwise 411 return new Response(403, {}, null); 412 }); 413 414 this.get('/regions', function({ regions }) { 415 return this.serialize(regions.all()); 416 }); 417 418 this.get('/operator/license', function({ features }) { 419 const records = features.all(); 420 421 if (records.length) { 422 return { 423 License: { 424 Features: records.models.mapBy('name'), 425 } 426 }; 427 } 428 429 return new Response(501, {}, null); 430 }); 431 432 const clientAllocationStatsHandler = function({ clientAllocationStats }, { params }) { 433 return this.serialize(clientAllocationStats.find(params.id)); 434 }; 435 436 const clientAllocationLog = function(server, { params, queryParams }) { 437 const allocation = server.allocations.find(params.allocation_id); 438 const tasks = allocation.taskStateIds.map(id => server.taskStates.find(id)); 439 440 if (!tasks.mapBy('name').includes(queryParams.task)) { 441 return new Response(400, {}, 'must include task name'); 442 } 443 444 if (queryParams.plain) { 445 return logFrames.join(''); 446 } 447 448 return logEncode(logFrames, logFrames.length - 1); 449 }; 450 451 const clientAllocationFSLsHandler = function({ allocFiles }, { queryParams: { path } }) { 452 const filterPath = path.endsWith('/') ? path.substr(0, path.length - 1) : path; 453 const files = filesForPath(allocFiles, filterPath); 454 return this.serialize(files); 455 }; 456 457 const clientAllocationFSStatHandler = function({ allocFiles }, { queryParams: { path } }) { 458 const filterPath = path.endsWith('/') ? path.substr(0, path.length - 1) : path; 459 460 // Root path 461 if (!filterPath) { 462 return this.serialize({ 463 IsDir: true, 464 ModTime: new Date(), 465 }); 466 } 467 468 // Either a file or a nested directory 469 const file = allocFiles.where({ path: filterPath }).models[0]; 470 return this.serialize(file); 471 }; 472 473 const clientAllocationCatHandler = function({ allocFiles }, { queryParams }) { 474 const [file, err] = fileOrError(allocFiles, queryParams.path); 475 476 if (err) return err; 477 return file.body; 478 }; 479 480 const clientAllocationStreamHandler = function({ allocFiles }, { queryParams }) { 481 const [file, err] = fileOrError(allocFiles, queryParams.path); 482 483 if (err) return err; 484 485 // Pretender, and therefore Mirage, doesn't support streaming responses. 486 return file.body; 487 }; 488 489 const clientAllocationReadAtHandler = function({ allocFiles }, { queryParams }) { 490 const [file, err] = fileOrError(allocFiles, queryParams.path); 491 492 if (err) return err; 493 return file.body.substr(queryParams.offset || 0, queryParams.limit); 494 }; 495 496 const fileOrError = function(allocFiles, path, message = 'Operation not allowed on a directory') { 497 // Root path 498 if (path === '/') { 499 return [null, new Response(400, {}, message)]; 500 } 501 502 const file = allocFiles.where({ path }).models[0]; 503 if (file.isDir) { 504 return [null, new Response(400, {}, message)]; 505 } 506 507 return [file, null]; 508 }; 509 510 // Client requests are available on the server and the client 511 this.put('/client/allocation/:id/restart', function() { 512 return new Response(204, {}, ''); 513 }); 514 515 this.get('/client/allocation/:id/stats', clientAllocationStatsHandler); 516 this.get('/client/fs/logs/:allocation_id', clientAllocationLog); 517 518 this.get('/client/fs/ls/:allocation_id', clientAllocationFSLsHandler); 519 this.get('/client/fs/stat/:allocation_id', clientAllocationFSStatHandler); 520 this.get('/client/fs/cat/:allocation_id', clientAllocationCatHandler); 521 this.get('/client/fs/stream/:allocation_id', clientAllocationStreamHandler); 522 this.get('/client/fs/readat/:allocation_id', clientAllocationReadAtHandler); 523 524 this.get('/client/stats', function({ clientStats }, { queryParams }) { 525 const seed = faker.random.number(10); 526 if (seed >= 8) { 527 const stats = clientStats.find(queryParams.node_id); 528 stats.update({ 529 timestamp: Date.now() * 1000000, 530 CPUTicksConsumed: stats.CPUTicksConsumed + faker.random.number({ min: -10, max: 10 }), 531 }); 532 return this.serialize(stats); 533 } else { 534 return new Response(500, {}, null); 535 } 536 }); 537 538 // TODO: in the future, this hack may be replaceable with dynamic host name 539 // support in pretender: https://github.com/pretenderjs/pretender/issues/210 540 HOSTS.forEach(host => { 541 this.get(`http://${host}/v1/client/allocation/:id/stats`, clientAllocationStatsHandler); 542 this.get(`http://${host}/v1/client/fs/logs/:allocation_id`, clientAllocationLog); 543 544 this.get(`http://${host}/v1/client/fs/ls/:allocation_id`, clientAllocationFSLsHandler); 545 this.get(`http://${host}/v1/client/stat/ls/:allocation_id`, clientAllocationFSStatHandler); 546 this.get(`http://${host}/v1/client/fs/cat/:allocation_id`, clientAllocationCatHandler); 547 this.get(`http://${host}/v1/client/fs/stream/:allocation_id`, clientAllocationStreamHandler); 548 this.get(`http://${host}/v1/client/fs/readat/:allocation_id`, clientAllocationReadAtHandler); 549 550 this.get(`http://${host}/v1/client/stats`, function({ clientStats }) { 551 return this.serialize(clientStats.find(host)); 552 }); 553 }); 554 555 this.get('/recommendations', function( 556 { jobs, namespaces, recommendations }, 557 { queryParams: { job: id, namespace } } 558 ) { 559 if (id) { 560 if (!namespaces.all().length) { 561 namespace = null; 562 } 563 564 const job = jobs.findBy({ id, namespace }); 565 566 if (!job) { 567 return []; 568 } 569 570 const taskGroups = job.taskGroups.models; 571 572 const tasks = taskGroups.reduce((tasks, taskGroup) => { 573 return tasks.concat(taskGroup.tasks.models); 574 }, []); 575 576 const recommendationIds = tasks.reduce((recommendationIds, task) => { 577 return recommendationIds.concat(task.recommendations.models.mapBy('id')); 578 }, []); 579 580 return recommendations.find(recommendationIds); 581 } else { 582 return recommendations.all(); 583 } 584 }); 585 586 this.post('/recommendations/apply', function({ recommendations }, { requestBody }) { 587 const { Apply, Dismiss } = JSON.parse(requestBody); 588 589 Apply.concat(Dismiss).forEach(id => { 590 const recommendation = recommendations.find(id); 591 const task = recommendation.task; 592 593 if (Apply.includes(id)) { 594 task.resources[recommendation.resource] = recommendation.value; 595 } 596 recommendation.destroy(); 597 task.save(); 598 }); 599 600 return {}; 601 }); 602 } 603 604 function filterKeys(object, ...keys) { 605 const clone = copy(object, true); 606 607 keys.forEach(key => { 608 delete clone[key]; 609 }); 610 611 return clone; 612 } 613 614 // An empty response but not a 204 No Content. This is still a valid JSON 615 // response that represents a payload with no worthwhile data. 616 function okEmpty() { 617 return new Response(200, {}, '{}'); 618 } 619 620 function generateFailedTGAllocs(job, taskGroups) { 621 const taskGroupsFromSpec = job.TaskGroups && job.TaskGroups.mapBy('Name'); 622 623 let tgNames = ['tg-one', 'tg-two']; 624 if (taskGroupsFromSpec && taskGroupsFromSpec.length) tgNames = taskGroupsFromSpec; 625 if (taskGroups && taskGroups.length) tgNames = taskGroups; 626 627 return tgNames.reduce((hash, tgName) => { 628 hash[tgName] = generateTaskGroupFailures(); 629 return hash; 630 }, {}); 631 }