github.com/hernad/nomad@v1.6.112/ui/app/models/job.js (about)

     1  /**
     2   * Copyright (c) HashiCorp, Inc.
     3   * SPDX-License-Identifier: MPL-2.0
     4   */
     5  
     6  import { alias, equal, or, and, mapBy } from '@ember/object/computed';
     7  import { computed } from '@ember/object';
     8  import Model from '@ember-data/model';
     9  import { attr, belongsTo, hasMany } from '@ember-data/model';
    10  import { fragment, fragmentArray } from 'ember-data-model-fragments/attributes';
    11  import RSVP from 'rsvp';
    12  import { assert } from '@ember/debug';
    13  import classic from 'ember-classic-decorator';
    14  
    15  const JOB_TYPES = ['service', 'batch', 'system', 'sysbatch'];
    16  
    17  @classic
    18  export default class Job extends Model {
    19    @attr('string') region;
    20    @attr('string') name;
    21    @attr('string') plainId;
    22    @attr('string') type;
    23    @attr('number') priority;
    24    @attr('boolean') allAtOnce;
    25  
    26    @attr('string') status;
    27    @attr('string') statusDescription;
    28    @attr('number') createIndex;
    29    @attr('number') modifyIndex;
    30    @attr('date') submitTime;
    31    @attr('string') nodePool; // Jobs are related to Node Pools either directly or via its Namespace, but no relationship.
    32  
    33    @fragment('structured-attributes') meta;
    34  
    35    get isPack() {
    36      return !!this.meta?.structured?.pack;
    37    }
    38  
    39    // True when the job is the parent periodic or parameterized jobs
    40    // Instances of periodic or parameterized jobs are false for both properties
    41    @attr('boolean') periodic;
    42    @attr('boolean') parameterized;
    43    @attr('boolean') dispatched;
    44  
    45    @attr() periodicDetails;
    46    @attr() parameterizedDetails;
    47  
    48    @computed('plainId')
    49    get idWithNamespace() {
    50      return `${this.plainId}@${this.belongsTo('namespace').id() ?? 'default'}`;
    51    }
    52  
    53    @computed('periodic', 'parameterized', 'dispatched')
    54    get hasChildren() {
    55      return this.periodic || (this.parameterized && !this.dispatched);
    56    }
    57  
    58    @computed('type')
    59    get hasClientStatus() {
    60      return this.type === 'system' || this.type === 'sysbatch';
    61    }
    62  
    63    @belongsTo('job', { inverse: 'children' }) parent;
    64    @hasMany('job', { inverse: 'parent' }) children;
    65  
    66    // The parent job name is prepended to child launch job names
    67    @computed('name', 'parent.content')
    68    get trimmedName() {
    69      return this.get('parent.content')
    70        ? this.name.replace(/.+?\//, '')
    71        : this.name;
    72    }
    73  
    74    // A composite of type and other job attributes to determine
    75    // a better type descriptor for human interpretation rather
    76    // than for scheduling.
    77    @computed('isPack', 'type', 'periodic', 'parameterized')
    78    get displayType() {
    79      if (this.periodic) {
    80        return { type: 'periodic', isPack: this.isPack };
    81      } else if (this.parameterized) {
    82        return { type: 'parameterized', isPack: this.isPack };
    83      }
    84      return { type: this.type, isPack: this.isPack };
    85    }
    86  
    87    // A composite of type and other job attributes to determine
    88    // type for templating rather than scheduling
    89    @computed(
    90      'type',
    91      'periodic',
    92      'parameterized',
    93      'parent.{periodic,parameterized}'
    94    )
    95    get templateType() {
    96      const type = this.type;
    97  
    98      if (this.get('parent.periodic')) {
    99        return 'periodic-child';
   100      } else if (this.get('parent.parameterized')) {
   101        return 'parameterized-child';
   102      } else if (this.periodic) {
   103        return 'periodic';
   104      } else if (this.parameterized) {
   105        return 'parameterized';
   106      } else if (JOB_TYPES.includes(type)) {
   107        // Guard against the API introducing a new type before the UI
   108        // is prepared to handle it.
   109        return this.type;
   110      }
   111  
   112      // A fail-safe in the event the API introduces a new type.
   113      return 'service';
   114    }
   115  
   116    @attr() datacenters;
   117    @fragmentArray('task-group', { defaultValue: () => [] }) taskGroups;
   118    @belongsTo('job-summary') summary;
   119  
   120    // A job model created from the jobs list response will be lacking
   121    // task groups. This is an indicator that it needs to be reloaded
   122    // if task group information is important.
   123    @equal('taskGroups.length', 0) isPartial;
   124  
   125    // If a job has only been loaded through the list request, the task groups
   126    // are still unknown. However, the count of task groups is available through
   127    // the job-summary model which is embedded in the jobs list response.
   128    @or('taskGroups.length', 'taskGroupSummaries.length') taskGroupCount;
   129  
   130    // Alias through to the summary, as if there was no relationship
   131    @alias('summary.taskGroupSummaries') taskGroupSummaries;
   132    @alias('summary.queuedAllocs') queuedAllocs;
   133    @alias('summary.startingAllocs') startingAllocs;
   134    @alias('summary.runningAllocs') runningAllocs;
   135    @alias('summary.completeAllocs') completeAllocs;
   136    @alias('summary.failedAllocs') failedAllocs;
   137    @alias('summary.lostAllocs') lostAllocs;
   138    @alias('summary.unknownAllocs') unknownAllocs;
   139    @alias('summary.totalAllocs') totalAllocs;
   140    @alias('summary.pendingChildren') pendingChildren;
   141    @alias('summary.runningChildren') runningChildren;
   142    @alias('summary.deadChildren') deadChildren;
   143    @alias('summary.totalChildren') totalChildren;
   144  
   145    @attr('number') version;
   146  
   147    @hasMany('job-versions') versions;
   148    @hasMany('allocations') allocations;
   149    @hasMany('deployments') deployments;
   150    @hasMany('evaluations') evaluations;
   151    @hasMany('variables') variables;
   152    @belongsTo('namespace') namespace;
   153    @belongsTo('job-scale') scaleState;
   154    @hasMany('services') services;
   155  
   156    @hasMany('recommendation-summary') recommendationSummaries;
   157  
   158    @computed('taskGroups.@each.drivers')
   159    get drivers() {
   160      return this.taskGroups
   161        .mapBy('drivers')
   162        .reduce((all, drivers) => {
   163          all.push(...drivers);
   164          return all;
   165        }, [])
   166        .uniq();
   167    }
   168  
   169    @mapBy('allocations', 'unhealthyDrivers') allocationsUnhealthyDrivers;
   170  
   171    // Getting all unhealthy drivers for a job can be incredibly expensive if the job
   172    // has many allocations. This can lead to making an API request for many nodes.
   173    @computed('allocations', 'allocationsUnhealthyDrivers.[]')
   174    get unhealthyDrivers() {
   175      return this.allocations
   176        .mapBy('unhealthyDrivers')
   177        .reduce((all, drivers) => {
   178          all.push(...drivers);
   179          return all;
   180        }, [])
   181        .uniq();
   182    }
   183  
   184    @computed('evaluations.@each.isBlocked')
   185    get hasBlockedEvaluation() {
   186      return this.evaluations
   187        .toArray()
   188        .some((evaluation) => evaluation.get('isBlocked'));
   189    }
   190  
   191    @and('latestFailureEvaluation', 'hasBlockedEvaluation') hasPlacementFailures;
   192  
   193    @computed('evaluations.{@each.modifyIndex,isPending}')
   194    get latestEvaluation() {
   195      const evaluations = this.evaluations;
   196      if (!evaluations || evaluations.get('isPending')) {
   197        return null;
   198      }
   199      return evaluations.sortBy('modifyIndex').get('lastObject');
   200    }
   201  
   202    @computed('evaluations.{@each.modifyIndex,isPending}')
   203    get latestFailureEvaluation() {
   204      const evaluations = this.evaluations;
   205      if (!evaluations || evaluations.get('isPending')) {
   206        return null;
   207      }
   208  
   209      const failureEvaluations = evaluations.filterBy('hasPlacementFailures');
   210      if (failureEvaluations) {
   211        return failureEvaluations.sortBy('modifyIndex').get('lastObject');
   212      }
   213  
   214      return undefined;
   215    }
   216  
   217    @equal('type', 'service') supportsDeployments;
   218  
   219    @belongsTo('deployment', { inverse: 'jobForLatest' }) latestDeployment;
   220  
   221    @computed('latestDeployment', 'latestDeployment.isRunning')
   222    get runningDeployment() {
   223      const latest = this.latestDeployment;
   224      if (latest.get('isRunning')) return latest;
   225      return undefined;
   226    }
   227  
   228    fetchRawDefinition() {
   229      return this.store.adapterFor('job').fetchRawDefinition(this);
   230    }
   231  
   232    fetchRawSpecification() {
   233      return this.store.adapterFor('job').fetchRawSpecification(this);
   234    }
   235  
   236    forcePeriodic() {
   237      return this.store.adapterFor('job').forcePeriodic(this);
   238    }
   239  
   240    stop() {
   241      return this.store.adapterFor('job').stop(this);
   242    }
   243  
   244    purge() {
   245      return this.store.adapterFor('job').purge(this);
   246    }
   247  
   248    plan() {
   249      assert('A job must be parsed before planned', this._newDefinitionJSON);
   250      return this.store.adapterFor('job').plan(this);
   251    }
   252  
   253    run() {
   254      assert('A job must be parsed before ran', this._newDefinitionJSON);
   255      return this.store.adapterFor('job').run(this);
   256    }
   257  
   258    update() {
   259      assert('A job must be parsed before updated', this._newDefinitionJSON);
   260  
   261      return this.store.adapterFor('job').update(this);
   262    }
   263  
   264    parse() {
   265      const definition = this._newDefinition;
   266      const variables = this._newDefinitionVariables;
   267      let promise;
   268  
   269      try {
   270        // If the definition is already JSON then it doesn't need to be parsed.
   271        const json = JSON.parse(definition);
   272        this.set('_newDefinitionJSON', json);
   273  
   274        // You can't set the ID of a record that already exists
   275        if (this.isNew) {
   276          this.setIdByPayload(json);
   277        }
   278  
   279        promise = RSVP.resolve(definition);
   280      } catch (err) {
   281        // If the definition is invalid JSON, assume it is HCL. If it is invalid
   282        // in anyway, the parse endpoint will throw an error.
   283  
   284        promise = this.store
   285          .adapterFor('job')
   286          .parse(this._newDefinition, variables)
   287          .then((response) => {
   288            this.set('_newDefinitionJSON', response);
   289            this.setIdByPayload(response);
   290          });
   291      }
   292  
   293      return promise;
   294    }
   295  
   296    scale(group, count, message) {
   297      if (message == null)
   298        message = `Manually scaled to ${count} from the Nomad UI`;
   299      return this.store.adapterFor('job').scale(this, group, count, message);
   300    }
   301  
   302    dispatch(meta, payload) {
   303      return this.store.adapterFor('job').dispatch(this, meta, payload);
   304    }
   305  
   306    setIdByPayload(payload) {
   307      const namespace = payload.Namespace || 'default';
   308      const id = payload.Name;
   309  
   310      this.set('plainId', id);
   311      this.set('_idBeforeSaving', JSON.stringify([id, namespace]));
   312  
   313      const namespaceRecord = this.store.peekRecord('namespace', namespace);
   314      if (namespaceRecord) {
   315        this.set('namespace', namespaceRecord);
   316      }
   317    }
   318  
   319    resetId() {
   320      this.set(
   321        'id',
   322        JSON.stringify([this.plainId, this.get('namespace.name') || 'default'])
   323      );
   324    }
   325  
   326    @computed('status')
   327    get statusClass() {
   328      const classMap = {
   329        pending: 'is-pending',
   330        running: 'is-primary',
   331        dead: 'is-light',
   332      };
   333  
   334      return classMap[this.status] || 'is-dark';
   335    }
   336  
   337    @attr('string') payload;
   338  
   339    @computed('payload')
   340    get decodedPayload() {
   341      // Lazily decode the base64 encoded payload
   342      return window.atob(this.payload || '');
   343    }
   344  
   345    // An arbitrary HCL or JSON string that is used by the serializer to plan
   346    // and run this job. Used for both new job models and saved job models.
   347    @attr('string') _newDefinition;
   348  
   349    // An arbitrary JSON string that is used by the adapter to plan
   350    // and run this job. Used for both new job models and saved job models.
   351    @attr('string') _newDefinitionVariables;
   352  
   353    // The new definition may be HCL, in which case the API will need to parse the
   354    // spec first. In order to preserve both the original HCL and the parsed response
   355    // that will be submitted to the create job endpoint, another prop is necessary.
   356    @attr('string') _newDefinitionJSON;
   357  
   358    @computed('variables', 'parent', 'plainId')
   359    get pathLinkedVariable() {
   360      if (this.parent.get('id')) {
   361        return this.variables?.findBy(
   362          'path',
   363          `nomad/jobs/${JSON.parse(this.parent.get('id'))[0]}`
   364        );
   365      } else {
   366        return this.variables?.findBy('path', `nomad/jobs/${this.plainId}`);
   367      }
   368    }
   369  }