github.com/anth0d/nomad@v0.0.0-20221214183521-ae3a0a2cad06/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  import formatHost from 'nomad-ui/utils/format-host';
     9  import faker from 'nomad-ui/mirage/faker';
    10  
    11  export function findLeader(schema) {
    12    const agent = schema.agents.first();
    13    return formatHost(agent.member.Address, agent.member.Tags.port);
    14  }
    15  
    16  export function filesForPath(allocFiles, filterPath) {
    17    return allocFiles.where(
    18      (file) =>
    19        (!filterPath || file.path.startsWith(filterPath)) &&
    20        file.path.length > filterPath.length &&
    21        !file.path.substr(filterPath.length + 1).includes('/')
    22    );
    23  }
    24  
    25  export default function () {
    26    this.timing = 0; // delay for each request, automatically set to 0 during testing
    27  
    28    this.logging = window.location.search.includes('mirage-logging=true');
    29  
    30    this.namespace = 'v1';
    31    this.trackRequests = Ember.testing;
    32  
    33    const nomadIndices = {}; // used for tracking blocking queries
    34    const server = this;
    35    const withBlockingSupport = function (fn) {
    36      return function (schema, request) {
    37        // Get the original response
    38        let { url } = request;
    39        url = url.replace(/index=\d+[&;]?/, '');
    40        const response = fn.apply(this, arguments);
    41  
    42        // Get and increment the appropriate index
    43        nomadIndices[url] || (nomadIndices[url] = 2);
    44        const index = nomadIndices[url];
    45        nomadIndices[url]++;
    46  
    47        // Annotate the response with the index
    48        if (response instanceof Response) {
    49          response.headers['x-nomad-index'] = index;
    50          return response;
    51        }
    52        return new Response(200, { 'x-nomad-index': index }, response);
    53      };
    54    };
    55  
    56    this.get(
    57      '/jobs',
    58      withBlockingSupport(function ({ jobs }, { queryParams }) {
    59        const json = this.serialize(jobs.all());
    60        const namespace = queryParams.namespace || 'default';
    61        return json
    62          .filter((job) => {
    63            if (namespace === '*') return true;
    64            return namespace === 'default'
    65              ? !job.NamespaceID || job.NamespaceID === namespace
    66              : job.NamespaceID === namespace;
    67          })
    68          .map((job) => filterKeys(job, 'TaskGroups', 'NamespaceID'));
    69      })
    70    );
    71  
    72    this.post('/jobs', function (schema, req) {
    73      const body = JSON.parse(req.requestBody);
    74  
    75      if (!body.Job)
    76        return new Response(
    77          400,
    78          {},
    79          'Job is a required field on the request payload'
    80        );
    81  
    82      return okEmpty();
    83    });
    84  
    85    this.post('/jobs/parse', function (schema, req) {
    86      const body = JSON.parse(req.requestBody);
    87  
    88      if (!body.JobHCL)
    89        return new Response(
    90          400,
    91          {},
    92          'JobHCL is a required field on the request payload'
    93        );
    94      if (!body.Canonicalize)
    95        return new Response(400, {}, 'Expected Canonicalize to be true');
    96  
    97      // Parse the name out of the first real line of HCL to match IDs in the new job record
    98      // Regex expectation:
    99      //   in:  job "job-name" {
   100      //   out: job-name
   101      const nameFromHCLBlock = /.+?"(.+?)"/;
   102      const jobName = body.JobHCL.trim()
   103        .split('\n')[0]
   104        .match(nameFromHCLBlock)[1];
   105  
   106      const job = server.create('job', { id: jobName });
   107      return new Response(200, {}, this.serialize(job));
   108    });
   109  
   110    this.post('/job/:id/plan', function (schema, req) {
   111      const body = JSON.parse(req.requestBody);
   112  
   113      if (!body.Job)
   114        return new Response(
   115          400,
   116          {},
   117          'Job is a required field on the request payload'
   118        );
   119      if (!body.Diff) return new Response(400, {}, 'Expected Diff to be true');
   120  
   121      const FailedTGAllocs =
   122        body.Job.Unschedulable && generateFailedTGAllocs(body.Job);
   123  
   124      return new Response(
   125        200,
   126        {},
   127        JSON.stringify({ FailedTGAllocs, Diff: generateDiff(req.params.id) })
   128      );
   129    });
   130  
   131    this.get(
   132      '/job/:id',
   133      withBlockingSupport(function ({ jobs }, { params, queryParams }) {
   134        const job = jobs.all().models.find((job) => {
   135          const jobIsDefault = !job.namespaceId || job.namespaceId === 'default';
   136          const qpIsDefault =
   137            !queryParams.namespace || queryParams.namespace === 'default';
   138          return (
   139            job.id === params.id &&
   140            (job.namespaceId === queryParams.namespace ||
   141              (jobIsDefault && qpIsDefault))
   142          );
   143        });
   144  
   145        return job ? this.serialize(job) : new Response(404, {}, null);
   146      })
   147    );
   148  
   149    this.post('/job/:id', function (schema, req) {
   150      const body = JSON.parse(req.requestBody);
   151  
   152      if (!body.Job)
   153        return new Response(
   154          400,
   155          {},
   156          'Job is a required field on the request payload'
   157        );
   158  
   159      return okEmpty();
   160    });
   161  
   162    this.get(
   163      '/job/:id/summary',
   164      withBlockingSupport(function ({ jobSummaries }, { params }) {
   165        return this.serialize(jobSummaries.findBy({ jobId: params.id }));
   166      })
   167    );
   168  
   169    this.get('/job/:id/allocations', function ({ allocations }, { params }) {
   170      return this.serialize(allocations.where({ jobId: params.id }));
   171    });
   172  
   173    this.get('/job/:id/versions', function ({ jobVersions }, { params }) {
   174      return this.serialize(jobVersions.where({ jobId: params.id }));
   175    });
   176  
   177    this.get('/job/:id/deployments', function ({ deployments }, { params }) {
   178      return this.serialize(deployments.where({ jobId: params.id }));
   179    });
   180  
   181    this.get('/job/:id/deployment', function ({ deployments }, { params }) {
   182      const deployment = deployments.where({ jobId: params.id }).models[0];
   183      return deployment
   184        ? this.serialize(deployment)
   185        : new Response(200, {}, 'null');
   186    });
   187  
   188    this.get(
   189      '/job/:id/scale',
   190      withBlockingSupport(function ({ jobScales }, { params }) {
   191        const obj = jobScales.findBy({ jobId: params.id });
   192        return this.serialize(jobScales.findBy({ jobId: params.id }));
   193      })
   194    );
   195  
   196    this.post('/job/:id/periodic/force', function (schema, { params }) {
   197      // Create the child job
   198      const parent = schema.jobs.find(params.id);
   199  
   200      // Use the server instead of the schema to leverage the job factory
   201      server.create('job', 'periodicChild', {
   202        parentId: parent.id,
   203        namespaceId: parent.namespaceId,
   204        namespace: parent.namespace,
   205        createAllocations: parent.createAllocations,
   206      });
   207  
   208      return okEmpty();
   209    });
   210  
   211    this.post('/job/:id/dispatch', function (schema, { params }) {
   212      // Create the child job
   213      const parent = schema.jobs.find(params.id);
   214  
   215      // Use the server instead of the schema to leverage the job factory
   216      let dispatched = server.create('job', 'parameterizedChild', {
   217        parentId: parent.id,
   218        namespaceId: parent.namespaceId,
   219        namespace: parent.namespace,
   220        createAllocations: parent.createAllocations,
   221      });
   222  
   223      return new Response(
   224        200,
   225        {},
   226        JSON.stringify({
   227          DispatchedJobID: dispatched.id,
   228        })
   229      );
   230    });
   231  
   232    this.post('/job/:id/revert', function ({ jobs }, { requestBody }) {
   233      const { JobID, JobVersion } = JSON.parse(requestBody);
   234      const job = jobs.find(JobID);
   235      job.version = JobVersion;
   236      job.save();
   237  
   238      return okEmpty();
   239    });
   240  
   241    this.post('/job/:id/scale', function ({ jobs }, { params }) {
   242      return this.serialize(jobs.find(params.id));
   243    });
   244  
   245    this.delete('/job/:id', function (schema, { params }) {
   246      const job = schema.jobs.find(params.id);
   247      job.update({ status: 'dead' });
   248      return new Response(204, {}, '');
   249    });
   250  
   251    this.get('/deployment/:id');
   252  
   253    this.post('/deployment/fail/:id', function () {
   254      return new Response(204, {}, '');
   255    });
   256  
   257    this.post('/deployment/promote/:id', function () {
   258      return new Response(204, {}, '');
   259    });
   260  
   261    this.get('/job/:id/evaluations', function ({ evaluations }, { params }) {
   262      return this.serialize(evaluations.where({ jobId: params.id }));
   263    });
   264  
   265    this.get('/evaluations');
   266    this.get('/evaluation/:id', function ({ evaluations }, { params }) {
   267      return evaluations.find(params.id);
   268    });
   269  
   270    this.get('/deployment/allocations/:id', function (schema, { params }) {
   271      const job = schema.jobs.find(schema.deployments.find(params.id).jobId);
   272      const allocations = schema.allocations.where({ jobId: job.id });
   273  
   274      return this.serialize(allocations.slice(0, 3));
   275    });
   276  
   277    this.get('/nodes', function ({ nodes }, req) {
   278      // authorize user permissions
   279      const token = server.db.tokens.findBy({
   280        secretId: req.requestHeaders['X-Nomad-Token'],
   281      });
   282  
   283      if (token) {
   284        const { policyIds } = token;
   285        const policies = server.db.policies.find(policyIds);
   286        const hasReadPolicy = policies.find(
   287          (p) =>
   288            p.rulesJSON.Node?.Policy === 'read' ||
   289            p.rulesJSON.Node?.Policy === 'write'
   290        );
   291        if (hasReadPolicy) {
   292          const json = this.serialize(nodes.all());
   293          return json;
   294        }
   295        return new Response(403, {}, 'Permissions have not be set-up.');
   296      }
   297  
   298      // TODO:  Think about policy handling in Mirage set-up
   299      return this.serialize(nodes.all());
   300    });
   301  
   302    this.get('/node/:id');
   303  
   304    this.get('/node/:id/allocations', function ({ allocations }, { params }) {
   305      return this.serialize(allocations.where({ nodeId: params.id }));
   306    });
   307  
   308    this.post(
   309      '/node/:id/eligibility',
   310      function ({ nodes }, { params, requestBody }) {
   311        const body = JSON.parse(requestBody);
   312        const node = nodes.find(params.id);
   313  
   314        node.update({ schedulingEligibility: body.Elibility === 'eligible' });
   315        return this.serialize(node);
   316      }
   317    );
   318  
   319    this.post('/node/:id/drain', function ({ nodes }, { params }) {
   320      return this.serialize(nodes.find(params.id));
   321    });
   322  
   323    this.get('/allocations');
   324  
   325    this.get('/allocation/:id');
   326  
   327    this.post('/allocation/:id/stop', function () {
   328      return new Response(204, {}, '');
   329    });
   330  
   331    this.get(
   332      '/volumes',
   333      withBlockingSupport(function ({ csiVolumes }, { queryParams }) {
   334        if (queryParams.type !== 'csi') {
   335          return new Response(200, {}, '[]');
   336        }
   337  
   338        const json = this.serialize(csiVolumes.all());
   339        const namespace = queryParams.namespace || 'default';
   340        return json.filter((volume) => {
   341          if (namespace === '*') return true;
   342          return namespace === 'default'
   343            ? !volume.NamespaceID || volume.NamespaceID === namespace
   344            : volume.NamespaceID === namespace;
   345        });
   346      })
   347    );
   348  
   349    this.get(
   350      '/volume/:id',
   351      withBlockingSupport(function ({ csiVolumes }, { params, queryParams }) {
   352        if (!params.id.startsWith('csi/')) {
   353          return new Response(404, {}, null);
   354        }
   355  
   356        const id = params.id.replace(/^csi\//, '');
   357        const volume = csiVolumes.all().models.find((volume) => {
   358          const volumeIsDefault =
   359            !volume.namespaceId || volume.namespaceId === 'default';
   360          const qpIsDefault =
   361            !queryParams.namespace || queryParams.namespace === 'default';
   362          return (
   363            volume.id === id &&
   364            (volume.namespaceId === queryParams.namespace ||
   365              (volumeIsDefault && qpIsDefault))
   366          );
   367        });
   368  
   369        return volume ? this.serialize(volume) : new Response(404, {}, null);
   370      })
   371    );
   372  
   373    this.get('/plugins', function ({ csiPlugins }, { queryParams }) {
   374      if (queryParams.type !== 'csi') {
   375        return new Response(200, {}, '[]');
   376      }
   377  
   378      return this.serialize(csiPlugins.all());
   379    });
   380  
   381    this.get('/plugin/:id', function ({ csiPlugins }, { params }) {
   382      if (!params.id.startsWith('csi/')) {
   383        return new Response(404, {}, null);
   384      }
   385  
   386      const id = params.id.replace(/^csi\//, '');
   387      const volume = csiPlugins.find(id);
   388  
   389      if (!volume) {
   390        return new Response(404, {}, null);
   391      }
   392  
   393      return this.serialize(volume);
   394    });
   395  
   396    this.get('/namespaces', function ({ namespaces }) {
   397      const records = namespaces.all();
   398  
   399      if (records.length) {
   400        return this.serialize(records);
   401      }
   402  
   403      return this.serialize([{ Name: 'default' }]);
   404    });
   405  
   406    this.get('/namespace/:id', function ({ namespaces }, { params }) {
   407      return this.serialize(namespaces.find(params.id));
   408    });
   409  
   410    this.get('/agent/members', function ({ agents, regions }) {
   411      const firstRegion = regions.first();
   412      return {
   413        ServerRegion: firstRegion ? firstRegion.id : null,
   414        Members: this.serialize(agents.all()).map(({ member }) => ({
   415          ...member,
   416        })),
   417      };
   418    });
   419  
   420    this.get('/agent/self', function ({ agents }) {
   421      return agents.first();
   422    });
   423  
   424    this.get('/agent/monitor', function ({ agents, nodes }, { queryParams }) {
   425      const serverId = queryParams.server_id;
   426      const clientId = queryParams.client_id;
   427  
   428      if (serverId && clientId)
   429        return new Response(400, {}, 'specify a client or a server, not both');
   430      if (serverId && !agents.findBy({ name: serverId }))
   431        return new Response(400, {}, 'specified server does not exist');
   432      if (clientId && !nodes.find(clientId))
   433        return new Response(400, {}, 'specified client does not exist');
   434  
   435      if (queryParams.plain) {
   436        return logFrames.join('');
   437      }
   438  
   439      return logEncode(logFrames, logFrames.length - 1);
   440    });
   441  
   442    this.get('/status/leader', function (schema) {
   443      return JSON.stringify(findLeader(schema));
   444    });
   445  
   446    this.get('/acl/tokens', function ({tokens}, req) {
   447      return this.serialize(tokens.all());
   448    });
   449  
   450    this.get('/acl/token/self', function ({ tokens }, req) {
   451      const secret = req.requestHeaders['X-Nomad-Token'];
   452      const tokenForSecret = tokens.findBy({ secretId: secret });
   453  
   454      // Return the token if it exists
   455      if (tokenForSecret) {
   456        return this.serialize(tokenForSecret);
   457      }
   458  
   459      // Client error if it doesn't
   460      return new Response(400, {}, null);
   461    });
   462  
   463    this.get('/acl/token/:id', function ({ tokens }, req) {
   464      const token = tokens.find(req.params.id);
   465      const secret = req.requestHeaders['X-Nomad-Token'];
   466      const tokenForSecret = tokens.findBy({ secretId: secret });
   467  
   468      // Return the token only if the request header matches the token
   469      // or the token is of type management
   470      if (
   471        token.secretId === secret ||
   472        (tokenForSecret && tokenForSecret.type === 'management')
   473      ) {
   474        return this.serialize(token);
   475      }
   476  
   477      // Return not authorized otherwise
   478      return new Response(403, {}, null);
   479    });
   480  
   481    this.post(
   482      '/acl/token/onetime/exchange',
   483      function ({ tokens }, { requestBody }) {
   484        const { OneTimeSecretID } = JSON.parse(requestBody);
   485  
   486        const tokenForSecret = tokens.findBy({ oneTimeSecret: OneTimeSecretID });
   487  
   488        // Return the token if it exists
   489        if (tokenForSecret) {
   490          return {
   491            Token: this.serialize(tokenForSecret),
   492          };
   493        }
   494  
   495        // Forbidden error if it doesn't
   496        return new Response(403, {}, null);
   497      }
   498    );
   499  
   500    this.get('/acl/policy/:id', function ({ policies, tokens }, req) {
   501      const policy = policies.findBy({ name: req.params.id });
   502      const secret = req.requestHeaders['X-Nomad-Token'];
   503      const tokenForSecret = tokens.findBy({ secretId: secret });
   504  
   505      if (req.params.id === 'anonymous') {
   506        if (policy) {
   507          return this.serialize(policy);
   508        } else {
   509          return new Response(404, {}, null);
   510        }
   511      }
   512  
   513      // Return the policy only if the token that matches the request header
   514      // includes the policy or if the token that matches the request header
   515      // is of type management
   516      if (
   517        tokenForSecret &&
   518        (tokenForSecret.policies.includes(policy) ||
   519          tokenForSecret.type === 'management')
   520      ) {
   521        return this.serialize(policy);
   522      }
   523  
   524      // Return not authorized otherwise
   525      return new Response(403, {}, null);
   526    });
   527  
   528    this.get('/acl/policies', function ({ policies }, req) {
   529      return this.serialize(policies.all());
   530    });
   531  
   532    this.delete('/acl/policy/:id', function (schema, request) {
   533      const { id } = request.params;
   534      schema.tokens.all().models.filter(token => token.policyIds.includes(id)).forEach(token => {
   535        token.update({ policyIds: token.policyIds.filter(pid => pid !== id) });
   536      });
   537      server.db.policies.remove(id);
   538      return '';
   539    });
   540  
   541    this.put('/acl/policy/:id', function (schema, request) {
   542      return new Response(200, {}, {});
   543    });
   544  
   545    this.post('/acl/policy/:id', function (schema, request) {
   546      const { Name, Description, Rules } = JSON.parse(request.requestBody);
   547      return server.create('policy', {
   548        name: Name,
   549        description: Description,
   550        rules: Rules,
   551      });
   552  
   553    });
   554  
   555    this.get('/regions', function ({ regions }) {
   556      return this.serialize(regions.all());
   557    });
   558  
   559    this.get('/operator/license', function ({ features }) {
   560      const records = features.all();
   561  
   562      if (records.length) {
   563        return {
   564          License: {
   565            Features: records.models.mapBy('name'),
   566          },
   567        };
   568      }
   569  
   570      return new Response(501, {}, null);
   571    });
   572  
   573    const clientAllocationStatsHandler = function (
   574      { clientAllocationStats },
   575      { params }
   576    ) {
   577      return this.serialize(clientAllocationStats.find(params.id));
   578    };
   579  
   580    const clientAllocationLog = function (server, { params, queryParams }) {
   581      const allocation = server.allocations.find(params.allocation_id);
   582      const tasks = allocation.taskStateIds.map((id) =>
   583        server.taskStates.find(id)
   584      );
   585  
   586      if (!tasks.mapBy('name').includes(queryParams.task)) {
   587        return new Response(400, {}, 'must include task name');
   588      }
   589  
   590      if (queryParams.plain) {
   591        return logFrames.join('');
   592      }
   593  
   594      return logEncode(logFrames, logFrames.length - 1);
   595    };
   596  
   597    const clientAllocationFSLsHandler = function (
   598      { allocFiles },
   599      { queryParams: { path } }
   600    ) {
   601      const filterPath = path.endsWith('/')
   602        ? path.substr(0, path.length - 1)
   603        : path;
   604      const files = filesForPath(allocFiles, filterPath);
   605      return this.serialize(files);
   606    };
   607  
   608    const clientAllocationFSStatHandler = function (
   609      { allocFiles },
   610      { queryParams: { path } }
   611    ) {
   612      const filterPath = path.endsWith('/')
   613        ? path.substr(0, path.length - 1)
   614        : path;
   615  
   616      // Root path
   617      if (!filterPath) {
   618        return this.serialize({
   619          IsDir: true,
   620          ModTime: new Date(),
   621        });
   622      }
   623  
   624      // Either a file or a nested directory
   625      const file = allocFiles.where({ path: filterPath }).models[0];
   626      return this.serialize(file);
   627    };
   628  
   629    const clientAllocationCatHandler = function (
   630      { allocFiles },
   631      { queryParams }
   632    ) {
   633      const [file, err] = fileOrError(allocFiles, queryParams.path);
   634  
   635      if (err) return err;
   636      return file.body;
   637    };
   638  
   639    const clientAllocationStreamHandler = function (
   640      { allocFiles },
   641      { queryParams }
   642    ) {
   643      const [file, err] = fileOrError(allocFiles, queryParams.path);
   644  
   645      if (err) return err;
   646  
   647      // Pretender, and therefore Mirage, doesn't support streaming responses.
   648      return file.body;
   649    };
   650  
   651    const clientAllocationReadAtHandler = function (
   652      { allocFiles },
   653      { queryParams }
   654    ) {
   655      const [file, err] = fileOrError(allocFiles, queryParams.path);
   656  
   657      if (err) return err;
   658      return file.body.substr(queryParams.offset || 0, queryParams.limit);
   659    };
   660  
   661    const fileOrError = function (
   662      allocFiles,
   663      path,
   664      message = 'Operation not allowed on a directory'
   665    ) {
   666      // Root path
   667      if (path === '/') {
   668        return [null, new Response(400, {}, message)];
   669      }
   670  
   671      const file = allocFiles.where({ path }).models[0];
   672      if (file.isDir) {
   673        return [null, new Response(400, {}, message)];
   674      }
   675  
   676      return [file, null];
   677    };
   678  
   679    // Client requests are available on the server and the client
   680    this.put('/client/allocation/:id/restart', function () {
   681      return new Response(204, {}, '');
   682    });
   683  
   684    this.get('/client/allocation/:id/stats', clientAllocationStatsHandler);
   685    this.get('/client/fs/logs/:allocation_id', clientAllocationLog);
   686  
   687    this.get('/client/fs/ls/:allocation_id', clientAllocationFSLsHandler);
   688    this.get('/client/fs/stat/:allocation_id', clientAllocationFSStatHandler);
   689    this.get('/client/fs/cat/:allocation_id', clientAllocationCatHandler);
   690    this.get('/client/fs/stream/:allocation_id', clientAllocationStreamHandler);
   691    this.get('/client/fs/readat/:allocation_id', clientAllocationReadAtHandler);
   692  
   693    this.get('/client/stats', function ({ clientStats }, { queryParams }) {
   694      const seed = faker.random.number(10);
   695      if (seed >= 8) {
   696        const stats = clientStats.find(queryParams.node_id);
   697        stats.update({
   698          timestamp: Date.now() * 1000000,
   699          CPUTicksConsumed:
   700            stats.CPUTicksConsumed + faker.random.number({ min: -10, max: 10 }),
   701        });
   702        return this.serialize(stats);
   703      } else {
   704        return new Response(500, {}, null);
   705      }
   706    });
   707  
   708    // TODO: in the future, this hack may be replaceable with dynamic host name
   709    // support in pretender: https://github.com/pretenderjs/pretender/issues/210
   710    HOSTS.forEach((host) => {
   711      this.get(
   712        `http://${host}/v1/client/allocation/:id/stats`,
   713        clientAllocationStatsHandler
   714      );
   715      this.get(
   716        `http://${host}/v1/client/fs/logs/:allocation_id`,
   717        clientAllocationLog
   718      );
   719  
   720      this.get(
   721        `http://${host}/v1/client/fs/ls/:allocation_id`,
   722        clientAllocationFSLsHandler
   723      );
   724      this.get(
   725        `http://${host}/v1/client/stat/ls/:allocation_id`,
   726        clientAllocationFSStatHandler
   727      );
   728      this.get(
   729        `http://${host}/v1/client/fs/cat/:allocation_id`,
   730        clientAllocationCatHandler
   731      );
   732      this.get(
   733        `http://${host}/v1/client/fs/stream/:allocation_id`,
   734        clientAllocationStreamHandler
   735      );
   736      this.get(
   737        `http://${host}/v1/client/fs/readat/:allocation_id`,
   738        clientAllocationReadAtHandler
   739      );
   740  
   741      this.get(`http://${host}/v1/client/stats`, function ({ clientStats }) {
   742        return this.serialize(clientStats.find(host));
   743      });
   744    });
   745  
   746    this.post(
   747      '/search/fuzzy',
   748      function (
   749        { allocations, jobs, nodes, taskGroups, csiPlugins },
   750        { requestBody }
   751      ) {
   752        const { Text } = JSON.parse(requestBody);
   753  
   754        const matchedAllocs = allocations.where((allocation) =>
   755          allocation.name.includes(Text)
   756        );
   757        const matchedGroups = taskGroups.where((taskGroup) =>
   758          taskGroup.name.includes(Text)
   759        );
   760        const matchedJobs = jobs.where((job) => job.name.includes(Text));
   761        const matchedNodes = nodes.where((node) => node.name.includes(Text));
   762        const matchedPlugins = csiPlugins.where((plugin) =>
   763          plugin.id.includes(Text)
   764        );
   765  
   766        const transformedAllocs = matchedAllocs.models.map((alloc) => ({
   767          ID: alloc.name,
   768          Scope: [alloc.namespace || 'default', alloc.id],
   769        }));
   770  
   771        const transformedGroups = matchedGroups.models.map((group) => ({
   772          ID: group.name,
   773          Scope: [group.job.namespace, group.job.id],
   774        }));
   775  
   776        const transformedJobs = matchedJobs.models.map((job) => ({
   777          ID: job.name,
   778          Scope: [job.namespace || 'default', job.id],
   779        }));
   780  
   781        const transformedNodes = matchedNodes.models.map((node) => ({
   782          ID: node.name,
   783          Scope: [node.id],
   784        }));
   785  
   786        const transformedPlugins = matchedPlugins.models.map((plugin) => ({
   787          ID: plugin.id,
   788        }));
   789  
   790        const truncatedAllocs = transformedAllocs.slice(0, 20);
   791        const truncatedGroups = transformedGroups.slice(0, 20);
   792        const truncatedJobs = transformedJobs.slice(0, 20);
   793        const truncatedNodes = transformedNodes.slice(0, 20);
   794        const truncatedPlugins = transformedPlugins.slice(0, 20);
   795  
   796        return {
   797          Matches: {
   798            allocs: truncatedAllocs,
   799            groups: truncatedGroups,
   800            jobs: truncatedJobs,
   801            nodes: truncatedNodes,
   802            plugins: truncatedPlugins,
   803          },
   804          Truncations: {
   805            allocs: truncatedAllocs.length < truncatedAllocs.length,
   806            groups: truncatedGroups.length < transformedGroups.length,
   807            jobs: truncatedJobs.length < transformedJobs.length,
   808            nodes: truncatedNodes.length < transformedNodes.length,
   809            plugins: truncatedPlugins.length < transformedPlugins.length,
   810          },
   811        };
   812      }
   813    );
   814  
   815    this.get(
   816      '/recommendations',
   817      function (
   818        { jobs, namespaces, recommendations },
   819        { queryParams: { job: id, namespace } }
   820      ) {
   821        if (id) {
   822          if (!namespaces.all().length) {
   823            namespace = null;
   824          }
   825  
   826          const job = jobs.findBy({ id, namespace });
   827  
   828          if (!job) {
   829            return [];
   830          }
   831  
   832          const taskGroups = job.taskGroups.models;
   833  
   834          const tasks = taskGroups.reduce((tasks, taskGroup) => {
   835            return tasks.concat(taskGroup.tasks.models);
   836          }, []);
   837  
   838          const recommendationIds = tasks.reduce((recommendationIds, task) => {
   839            return recommendationIds.concat(
   840              task.recommendations.models.mapBy('id')
   841            );
   842          }, []);
   843  
   844          return recommendations.find(recommendationIds);
   845        } else {
   846          return recommendations.all();
   847        }
   848      }
   849    );
   850  
   851    this.post(
   852      '/recommendations/apply',
   853      function ({ recommendations }, { requestBody }) {
   854        const { Apply, Dismiss } = JSON.parse(requestBody);
   855  
   856        Apply.concat(Dismiss).forEach((id) => {
   857          const recommendation = recommendations.find(id);
   858          const task = recommendation.task;
   859  
   860          if (Apply.includes(id)) {
   861            task.resources[recommendation.resource] = recommendation.value;
   862          }
   863          recommendation.destroy();
   864          task.save();
   865        });
   866  
   867        return {};
   868      }
   869    );
   870  
   871    //#region Variables
   872  
   873    this.get('/vars', function (schema, { queryParams: { namespace } }) {
   874      if (namespace && namespace !== '*') {
   875        return schema.variables.all().filter((v) => v.namespace === namespace);
   876      } else {
   877        return schema.variables.all();
   878      }
   879    });
   880  
   881    this.get('/var/:id', function ({ variables }, { params }) {
   882      return variables.find(params.id);
   883    });
   884  
   885    this.put('/var/:id', function (schema, request) {
   886      const { Path, Namespace, Items } = JSON.parse(request.requestBody);
   887      if (request.url.includes('cas=') && Path === 'Auto-conflicting Variable') {
   888        return new Response(
   889          409,
   890          {},
   891          {
   892            CreateIndex: 65,
   893            CreateTime: faker.date.recent(14) * 1000000, // in the past couple weeks
   894            Items: { edited_by: 'your_remote_pal' },
   895            ModifyIndex: 2118,
   896            ModifyTime: faker.date.recent(0.01) * 1000000, // a few minutes ago
   897            Namespace: Namespace,
   898            Path: Path,
   899          }
   900        );
   901      } else {
   902        return server.create('variable', {
   903          path: Path,
   904          namespace: Namespace,
   905          items: Items,
   906          id: Path,
   907        });
   908      }
   909    });
   910  
   911    this.delete('/var/:id', function (schema, request) {
   912      const { id } = request.params;
   913      server.db.variables.remove(id);
   914      return '';
   915    });
   916  
   917    //#endregion Variables
   918  
   919    //#region Services
   920  
   921    const allocationServiceChecksHandler = function (schema) {
   922      let disasters = [
   923        "Moon's haunted",
   924        'reticulating splines',
   925        'The operation completed unexpectedly',
   926        'Ran out of sriracha :(',
   927        '¯\\_(ツ)_/¯',
   928        '<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"\n        "http://www.w3.org/TR/html4/strict.dtd">\n<html>\n    <head>\n        <meta http-equiv="Content-Type" content="text/html;charset=utf-8">\n        <title>Error response</title>\n    </head>\n    <body>\n        <h1>Error response</h1>\n        <p>Error code: 404</p>\n        <p>Message: File not found.</p>\n        <p>Error code explanation: HTTPStatus.NOT_FOUND - Nothing matches the given URI.</p>\n    </body>\n</html>\n',
   929      ];
   930      let fakeChecks = [];
   931      schema.serviceFragments.all().models.forEach((frag, iter) => {
   932        [...Array(iter)].forEach((check, checkIter) => {
   933          const checkOK = faker.random.boolean();
   934          fakeChecks.push({
   935            Check: `check-${checkIter}`,
   936            Group: `job-name.${frag.taskGroup?.name}[1]`,
   937            Output: checkOK
   938              ? 'nomad: http ok'
   939              : disasters[Math.floor(Math.random() * disasters.length)],
   940            Service: frag.name,
   941            Status: checkOK ? 'success' : 'failure',
   942            StatusCode: checkOK ? 200 : 400,
   943            Task: frag.task?.name,
   944            Timestamp: new Date().getTime(),
   945          });
   946        });
   947      });
   948      return fakeChecks;
   949    };
   950  
   951    this.get('/job/:id/services', function (schema, { params }) {
   952      const { services } = schema;
   953      return this.serialize(services.where({ jobId: params.id }));
   954    });
   955  
   956    this.get('/client/allocation/:id/checks', allocationServiceChecksHandler);
   957  
   958    //#endregion Services
   959  
   960    //#region SSO
   961    this.get('/acl/auth-methods', function (schema, request) {
   962      return schema.authMethods.all();
   963    });
   964    this.post('/acl/oidc/auth-url', (schema, req) => {
   965      const {AuthMethod, ClientNonce, RedirectUri, Meta} = JSON.parse(req.requestBody);
   966      return new Response(200, {}, {
   967        AuthURL: `/ui/oidc-mock?auth_method=${AuthMethod}&client_nonce=${ClientNonce}&redirect_uri=${RedirectUri}&meta=${Meta}`
   968      });
   969    });
   970  
   971    // Simulate an OIDC callback by assuming the code passed is the secret of an existing token, and return that token.
   972    this.post('/acl/oidc/complete-auth', function (schema, req) {
   973      const code = JSON.parse(req.requestBody).Code;
   974      const token = schema.tokens.findBy({
   975        id: code
   976      });
   977  
   978      return new Response(200, {}, {
   979        ACLToken: token.secretId
   980      });
   981    }, {timing: 1000});
   982  
   983  
   984  
   985  
   986    //#endregion SSO
   987  }
   988  
   989  function filterKeys(object, ...keys) {
   990    const clone = copy(object, true);
   991  
   992    keys.forEach((key) => {
   993      delete clone[key];
   994    });
   995  
   996    return clone;
   997  }
   998  
   999  // An empty response but not a 204 No Content. This is still a valid JSON
  1000  // response that represents a payload with no worthwhile data.
  1001  function okEmpty() {
  1002    return new Response(200, {}, '{}');
  1003  }
  1004  
  1005  function generateFailedTGAllocs(job, taskGroups) {
  1006    const taskGroupsFromSpec = job.TaskGroups && job.TaskGroups.mapBy('Name');
  1007  
  1008    let tgNames = ['tg-one', 'tg-two'];
  1009    if (taskGroupsFromSpec && taskGroupsFromSpec.length)
  1010      tgNames = taskGroupsFromSpec;
  1011    if (taskGroups && taskGroups.length) tgNames = taskGroups;
  1012  
  1013    return tgNames.reduce((hash, tgName) => {
  1014      hash[tgName] = generateTaskGroupFailures();
  1015      return hash;
  1016    }, {});
  1017  }