-
Notifications
You must be signed in to change notification settings - Fork 2k
/
Copy pathjob.js
679 lines (580 loc) · 21.8 KB
/
job.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
// @ts-check
import { alias, equal, or, and, mapBy } from '@ember/object/computed';
import { computed } from '@ember/object';
import Model from '@ember-data/model';
import { attr, belongsTo, hasMany } from '@ember-data/model';
import { fragment, fragmentArray } from 'ember-data-model-fragments/attributes';
import RSVP from 'rsvp';
import { assert } from '@ember/debug';
import classic from 'ember-classic-decorator';
import { jobAllocStatuses } from '../utils/allocation-client-statuses';
const JOB_TYPES = ['service', 'batch', 'system', 'sysbatch'];
@classic
export default class Job extends Model {
@attr('string') region;
@attr('string') name;
@attr('string') plainId;
@attr('string') type;
@attr('number') priority;
@attr('boolean') allAtOnce;
@attr('string') status;
@attr('string') statusDescription;
@attr('number') createIndex;
@attr('number') modifyIndex;
@attr('date') submitTime;
@attr('string') nodePool; // Jobs are related to Node Pools either directly or via its Namespace, but no relationship.
@attr() ui;
@attr('number') groupCountSum;
// if it's a system/sysbatch job, groupCountSum is allocs uniqued by nodeID
get expectedRunningAllocCount() {
if (this.type === 'system' || this.type === 'sysbatch') {
return this.allocations.filterBy('nodeID').uniqBy('nodeID').length;
} else {
return this.groupCountSum;
}
}
/**
* @typedef {Object} LatestDeploymentSummary
* @property {boolean} IsActive - Whether the deployment is currently active
* @property {number} JobVersion - The version of the job that was deployed
* @property {string} Status - The status of the deployment
* @property {string} StatusDescription - A description of the deployment status
* @property {boolean} AllAutoPromote - Whether all allocations were auto-promoted
* @property {boolean} RequiresPromotion - Whether the deployment requires promotion
*/
@attr() latestDeploymentSummary;
@attr() childStatuses;
get childStatusBreakdown() {
// child statuses is something like ['dead', 'dead', 'complete', 'running', 'running', 'dead'].
// Return an object counting by status, like {dead: 3, complete: 1, running: 2}
if (!this.childStatuses) return {};
const breakdown = {};
this.childStatuses.forEach((status) => {
if (breakdown[status]) {
breakdown[status]++;
} else {
breakdown[status] = 1;
}
});
return breakdown;
}
// When we detect the deletion/purge of a job from within that job page, we kick the user out to the jobs index.
// But what about when that purge is detected from the jobs index?
// We set this flag to true to let the user know that the job has been removed without simply nixing it from view.
@attr('boolean', { defaultValue: false }) assumeGC;
get allocTypes() {
return jobAllocStatuses[this.type].map((type) => {
return {
label: type,
};
});
}
/**
* @typedef {Object} CurrentStatus
* @property {"Healthy"|"Failed"|"Deploying"|"Degraded"|"Recovering"|"Complete"|"Running"|"Removed"} label - The current status of the job
* @property {"highlight"|"success"|"warning"|"critical"|"neutral"} state -
*/
/**
* @typedef {Object} HealthStatus
* @property {Array} nonCanary
* @property {Array} canary
*/
/**
* @typedef {Object} AllocationStatus
* @property {HealthStatus} healthy
* @property {HealthStatus} unhealthy
* @property {HealthStatus} health unknown
*/
/**
* @typedef {Object} AllocationBlock
* @property {AllocationStatus} [running]
* @property {AllocationStatus} [pending]
* @property {AllocationStatus} [failed]
* @property {AllocationStatus} [lost]
* @property {AllocationStatus} [unplaced]
* @property {AllocationStatus} [complete]
*/
/**
* Looks through running/pending allocations with the aim of filling up your desired number of allocations.
* If any desired remain, it will walk backwards through job versions and other allocation types to build
* a picture of the job's overall status.
*
* @returns {AllocationBlock} An object containing healthy non-canary allocations
* for each clientStatus.
*/
get allocBlocks() {
let availableSlotsToFill = this.expectedRunningAllocCount;
// Initialize allocationsOfShowableType with empty arrays for each clientStatus
/**
* @type {AllocationBlock}
*/
let allocationsOfShowableType = this.allocTypes.reduce(
(accumulator, type) => {
accumulator[type.label] = { healthy: { nonCanary: [] } };
return accumulator;
},
{}
);
// First accumulate the Running/Pending allocations
for (const alloc of this.allocations.filter(
(a) => a.clientStatus === 'running' || a.clientStatus === 'pending'
)) {
if (availableSlotsToFill === 0) {
break;
}
const status = alloc.clientStatus;
allocationsOfShowableType[status].healthy.nonCanary.push(alloc);
availableSlotsToFill--;
}
// TODO: return early here if !availableSlotsToFill
// Sort all allocs by jobVersion in descending order
const sortedAllocs = this.allocations
.filter(
(a) => a.clientStatus !== 'running' && a.clientStatus !== 'pending'
)
.sort((a, b) => {
// First sort by jobVersion
if (a.jobVersion > b.jobVersion) return 1;
if (a.jobVersion < b.jobVersion) return -1;
// If jobVersion is the same, sort by status order
// For example, we may have some allocBlock slots to fill, and need to determine
// if the user expects to see, from non-running/non-pending allocs, some old "failed" ones
// or "lost" or "complete" ones, etc. jobAllocStatuses give us this order.
if (a.jobVersion === b.jobVersion) {
return (
jobAllocStatuses[this.type].indexOf(b.clientStatus) -
jobAllocStatuses[this.type].indexOf(a.clientStatus)
);
} else {
return 0;
}
})
.reverse();
// Iterate over the sorted allocs
for (const alloc of sortedAllocs) {
if (availableSlotsToFill === 0) {
break;
}
const status = alloc.clientStatus;
// If the alloc has another clientStatus, add it to the corresponding list
// as long as we haven't reached the expectedRunningAllocCount limit for that clientStatus
if (
this.allocTypes.map(({ label }) => label).includes(status) &&
allocationsOfShowableType[status].healthy.nonCanary.length <
this.expectedRunningAllocCount
) {
allocationsOfShowableType[status].healthy.nonCanary.push(alloc);
availableSlotsToFill--;
}
}
// Handle unplaced allocs
if (availableSlotsToFill > 0) {
// TODO: JSDoc types for unhealty and health unknown aren't optional, but should be.
allocationsOfShowableType['unplaced'] = {
healthy: {
nonCanary: Array(availableSlotsToFill)
.fill()
.map(() => {
return { clientStatus: 'unplaced' };
}),
},
};
}
return allocationsOfShowableType;
}
/**
* A single status to indicate how a job is doing, based on running/healthy allocations vs desired.
* Possible statuses are:
* - Deploying: A deployment is actively taking place
* - Complete: (Batch/Sysbatch only) All expected allocations are complete
* - Running: (Batch/Sysbatch only) All expected allocations are running
* - Healthy: All expected allocations are running and healthy
* - Recovering: Some allocations are pending
* - Degraded: A deployment is not taking place, and some allocations are failed, lost, or unplaced
* - Failed: All allocations are failed, lost, or unplaced
* - Removed: The job appeared in our initial query, but has since been garbage collected
* @returns {CurrentStatus}
*/
/**
* A general assessment for how a job is going, in a non-deployment state
* @returns {CurrentStatus}
*/
get aggregateAllocStatus() {
let totalAllocs = this.expectedRunningAllocCount;
// If deploying:
if (this.latestDeploymentSummary?.IsActive) {
return { label: 'Deploying', state: 'highlight' };
}
// If the job was requested initially, but a subsequent request for it was
// not found, we can remove links to it but maintain its presence in the list
// until the user specifies they want a refresh
if (this.assumeGC) {
return { label: 'Removed', state: 'neutral' };
}
if (this.type === 'batch' || this.type === 'sysbatch') {
// TODO: showing as failed when long-complete
// If all the allocs are complete, the job is Complete
const completeAllocs = this.allocBlocks.complete?.healthy?.nonCanary;
if (completeAllocs?.length === totalAllocs) {
return { label: 'Complete', state: 'success' };
}
// If any allocations are running the job is "Running"
const healthyAllocs = this.allocBlocks.running?.healthy?.nonCanary;
if (healthyAllocs?.length + completeAllocs?.length === totalAllocs) {
return { label: 'Running', state: 'success' };
}
}
// All the exepected allocs are running and healthy? Congratulations!
const healthyAllocs = this.allocBlocks.running?.healthy?.nonCanary;
if (totalAllocs && healthyAllocs?.length === totalAllocs) {
return { label: 'Healthy', state: 'success' };
}
// If any allocations are pending the job is "Recovering"
// Note: Batch/System jobs (which do not have deployments)
// go into "recovering" right away, since some of their statuses are
// "pending" as they come online. This feels a little wrong but it's kind
// of correct?
const pendingAllocs = this.allocBlocks.pending?.healthy?.nonCanary;
if (pendingAllocs?.length > 0) {
return { label: 'Recovering', state: 'highlight' };
}
// If any allocations are failed, lost, or unplaced in a steady state,
// the job is "Degraded"
const failedOrLostAllocs = [
...this.allocBlocks.failed?.healthy?.nonCanary,
...this.allocBlocks.lost?.healthy?.nonCanary,
...this.allocBlocks.unplaced?.healthy?.nonCanary,
];
if (failedOrLostAllocs.length >= totalAllocs) {
return { label: 'Failed', state: 'critical' };
} else {
return { label: 'Degraded', state: 'warning' };
}
}
@fragment('structured-attributes') meta;
get isPack() {
return !!this.meta?.structured?.pack;
}
/**
* A task with a schedule block can have execution paused at specific cron-based times.
* If one is currently paused, an allocation at /statuses will come back with hasPausedTask=true.
* We should represent this to the user in the job row.
*/
get hasPausedTask() {
if (!this.allocations) {
return false;
}
return this.allocations.any((alloc) => alloc.hasPausedTask);
}
// True when the job is the parent periodic or parameterized jobs
// Instances of periodic or parameterized jobs are false for both properties
@attr('boolean') periodic;
@attr('boolean') parameterized;
@attr('boolean') dispatched;
@attr() periodicDetails;
@attr() parameterizedDetails;
@computed('plainId')
get idWithNamespace() {
return `${this.plainId}@${this.belongsTo('namespace').id() ?? 'default'}`;
}
@computed('periodic', 'parameterized', 'dispatched')
get hasChildren() {
return this.periodic || (this.parameterized && !this.dispatched);
}
@computed('type')
get hasClientStatus() {
return this.type === 'system' || this.type === 'sysbatch';
}
@belongsTo('job', { inverse: 'children' }) parent;
@hasMany('job', { inverse: 'parent' }) children;
// The parent job name is prepended to child launch job names
@computed('name', 'parent.content')
get trimmedName() {
return this.get('parent.content')
? this.name.replace(/.+?\//, '')
: this.name;
}
// A composite of type and other job attributes to determine
// a better type descriptor for human interpretation rather
// than for scheduling.
@computed('isPack', 'type', 'periodic', 'parameterized')
get displayType() {
if (this.periodic) {
return { type: 'periodic', isPack: this.isPack };
} else if (this.parameterized) {
return { type: 'parameterized', isPack: this.isPack };
}
return { type: this.type, isPack: this.isPack };
}
// A composite of type and other job attributes to determine
// type for templating rather than scheduling
@computed(
'type',
'periodic',
'parameterized',
'parent.{periodic,parameterized}'
)
get templateType() {
const type = this.type;
if (this.get('parent.periodic')) {
return 'periodic-child';
} else if (this.get('parent.parameterized')) {
return 'parameterized-child';
} else if (this.periodic) {
return 'periodic';
} else if (this.parameterized) {
return 'parameterized';
} else if (JOB_TYPES.includes(type)) {
// Guard against the API introducing a new type before the UI
// is prepared to handle it.
return this.type;
}
// A fail-safe in the event the API introduces a new type.
return 'service';
}
@attr() datacenters;
@fragmentArray('task-group', { defaultValue: () => [] }) taskGroups;
@belongsTo('job-summary') summary;
// A job model created from the jobs list response will be lacking
// task groups. This is an indicator that it needs to be reloaded
// if task group information is important.
@equal('taskGroups.length', 0) isPartial;
// If a job has only been loaded through the list request, the task groups
// are still unknown. However, the count of task groups is available through
// the job-summary model which is embedded in the jobs list response.
@or('taskGroups.length', 'taskGroupSummaries.length') taskGroupCount;
// Alias through to the summary, as if there was no relationship
@alias('summary.taskGroupSummaries') taskGroupSummaries;
@alias('summary.queuedAllocs') queuedAllocs;
@alias('summary.startingAllocs') startingAllocs;
@alias('summary.runningAllocs') runningAllocs;
@alias('summary.completeAllocs') completeAllocs;
@alias('summary.failedAllocs') failedAllocs;
@alias('summary.lostAllocs') lostAllocs;
@alias('summary.unknownAllocs') unknownAllocs;
@alias('summary.totalAllocs') totalAllocs;
@alias('summary.pendingChildren') pendingChildren;
@alias('summary.runningChildren') runningChildren;
@alias('summary.deadChildren') deadChildren;
@alias('summary.totalChildren') totalChildren;
@attr('number') version;
@hasMany('job-versions') versions;
@hasMany('allocations') allocations;
@hasMany('deployments') deployments;
@hasMany('evaluations') evaluations;
@hasMany('variables') variables;
@belongsTo('namespace') namespace;
@belongsTo('job-scale') scaleState;
@hasMany('services') services;
@hasMany('recommendation-summary') recommendationSummaries;
get actions() {
return this.taskGroups.reduce((acc, taskGroup) => {
return acc.concat(
taskGroup.tasks
.map((task) => {
return task.get('actions')?.toArray() || [];
})
.reduce((taskAcc, taskActions) => taskAcc.concat(taskActions), [])
);
}, []);
}
/**
*
* @param {import('../models/action').default} action
* @param {string} allocID
* @param {import('../models/action-instance').default} actionInstance
* @returns
*/
getActionSocketUrl(action, allocID, actionInstance) {
return this.store
.adapterFor('job')
.getActionSocketUrl(this, action, allocID, actionInstance);
}
@computed('[email protected]')
get drivers() {
return this.taskGroups
.mapBy('drivers')
.reduce((all, drivers) => {
all.push(...drivers);
return all;
}, [])
.uniq();
}
@mapBy('allocations', 'unhealthyDrivers') allocationsUnhealthyDrivers;
// Getting all unhealthy drivers for a job can be incredibly expensive if the job
// has many allocations. This can lead to making an API request for many nodes.
@computed('allocations', 'allocationsUnhealthyDrivers.[]')
get unhealthyDrivers() {
return this.allocations
.mapBy('unhealthyDrivers')
.reduce((all, drivers) => {
all.push(...drivers);
return all;
}, [])
.uniq();
}
@computed('[email protected]')
get hasBlockedEvaluation() {
return this.evaluations
.toArray()
.some((evaluation) => evaluation.get('isBlocked'));
}
@and('latestFailureEvaluation', 'hasBlockedEvaluation') hasPlacementFailures;
@computed('evaluations.{@each.modifyIndex,isPending}')
get latestEvaluation() {
const evaluations = this.evaluations;
if (!evaluations || evaluations.get('isPending')) {
return null;
}
return evaluations.sortBy('modifyIndex').get('lastObject');
}
@computed('evaluations.{@each.modifyIndex,isPending}')
get latestFailureEvaluation() {
const evaluations = this.evaluations;
if (!evaluations || evaluations.get('isPending')) {
return null;
}
const failureEvaluations = evaluations.filterBy('hasPlacementFailures');
if (failureEvaluations) {
return failureEvaluations.sortBy('modifyIndex').get('lastObject');
}
return undefined;
}
@equal('type', 'service') supportsDeployments;
@belongsTo('deployment', { inverse: 'jobForLatest' }) latestDeployment;
@computed('latestDeployment', 'latestDeployment.isRunning')
get runningDeployment() {
const latest = this.latestDeployment;
if (latest.get('isRunning')) return latest;
return undefined;
}
fetchRawDefinition() {
return this.store.adapterFor('job').fetchRawDefinition(this);
}
fetchRawSpecification() {
return this.store.adapterFor('job').fetchRawSpecification(this);
}
forcePeriodic() {
return this.store.adapterFor('job').forcePeriodic(this);
}
stop() {
return this.store.adapterFor('job').stop(this);
}
purge() {
return this.store.adapterFor('job').purge(this);
}
plan() {
assert('A job must be parsed before planned', this._newDefinitionJSON);
return this.store.adapterFor('job').plan(this);
}
run() {
assert('A job must be parsed before ran', this._newDefinitionJSON);
return this.store.adapterFor('job').run(this);
}
update() {
assert('A job must be parsed before updated', this._newDefinitionJSON);
return this.store.adapterFor('job').update(this);
}
parse() {
const definition = this._newDefinition;
const variables = this._newDefinitionVariables;
let promise;
try {
// If the definition is already JSON then it doesn't need to be parsed.
const json = JSON.parse(definition);
this.set('_newDefinitionJSON', json);
// You can't set the ID of a record that already exists
if (this.isNew) {
this.setIdByPayload(json);
}
promise = RSVP.resolve(definition);
} catch (err) {
// If the definition is invalid JSON, assume it is HCL. If it is invalid
// in anyway, the parse endpoint will throw an error.
promise = this.store
.adapterFor('job')
.parse(this._newDefinition, variables)
.then((response) => {
this.set('_newDefinitionJSON', response);
this.setIdByPayload(response);
});
}
return promise;
}
scale(group, count, message) {
if (message == null)
message = `Manually scaled to ${count} from the Nomad UI`;
return this.store.adapterFor('job').scale(this, group, count, message);
}
dispatch(meta, payload) {
return this.store.adapterFor('job').dispatch(this, meta, payload);
}
setIdByPayload(payload) {
const namespace = payload.Namespace || 'default';
const id = payload.Name;
this.set('plainId', id);
this.set('_idBeforeSaving', JSON.stringify([id, namespace]));
const namespaceRecord = this.store.peekRecord('namespace', namespace);
if (namespaceRecord) {
this.set('namespace', namespaceRecord);
}
}
resetId() {
this.set(
'id',
JSON.stringify([this.plainId, this.get('namespace.name') || 'default'])
);
}
@computed('status')
get statusClass() {
const classMap = {
pending: 'is-pending',
running: 'is-primary',
dead: 'is-light',
};
return classMap[this.status] || 'is-dark';
}
@attr('string') payload;
@computed('payload')
get decodedPayload() {
// Lazily decode the base64 encoded payload
return window.atob(this.payload || '');
}
// An arbitrary HCL or JSON string that is used by the serializer to plan
// and run this job. Used for both new job models and saved job models.
@attr('string') _newDefinition;
// An arbitrary JSON string that is used by the adapter to plan
// and run this job. Used for both new job models and saved job models.
@attr('string') _newDefinitionVariables;
// The new definition may be HCL, in which case the API will need to parse the
// spec first. In order to preserve both the original HCL and the parsed response
// that will be submitted to the create job endpoint, another prop is necessary.
@attr('string') _newDefinitionJSON;
@computed('variables.[]', 'parent', 'plainId')
get pathLinkedVariable() {
if (this.parent.get('id')) {
return this.variables?.findBy(
'path',
`nomad/jobs/${JSON.parse(this.parent.get('id'))[0]}`
);
} else {
return this.variables?.findBy('path', `nomad/jobs/${this.plainId}`);
}
}
// TODO: This async fetcher seems like a better fit for most of our use-cases than the above getter (which cannot do async/await)
async getPathLinkedVariable() {
await this.variables;
if (this.parent.get('id')) {
return this.variables?.findBy(
'path',
`nomad/jobs/${JSON.parse(this.parent.get('id'))[0]}`
);
} else {
return this.variables?.findBy('path', `nomad/jobs/${this.plainId}`);
}
}
}