teraslice 2.17.4 → 3.0.0-dev.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,481 +0,0 @@
1
- import { TSError, get, isEmpty, pDelay, pRetry } from '@terascope/utils';
2
- import KubeClient from 'kubernetes-client';
3
- // @ts-expect-error
4
- import Request from 'kubernetes-client/backends/request/index.js';
5
- import { getRetryConfig } from './utils.js';
6
- // @ts-expect-error
7
- const { Client, KubeConfig } = KubeClient;
8
- export class K8s {
9
- logger;
10
- apiPollDelay;
11
- defaultNamespace;
12
- shutdownTimeout;
13
- client;
14
- constructor(logger, clientConfig, defaultNamespace, apiPollDelay, shutdownTimeout) {
15
- this.apiPollDelay = apiPollDelay;
16
- this.defaultNamespace = defaultNamespace || 'default';
17
- this.logger = logger;
18
- this.shutdownTimeout = shutdownTimeout; // this is in milliseconds
19
- if (clientConfig) {
20
- this.client = new Client({
21
- config: clientConfig
22
- });
23
- }
24
- else if (process.env.KUBERNETES_SERVICE_HOST && process.env.KUBERNETES_SERVICE_PORT) {
25
- // configures the client when running inside k8s
26
- const kubeconfig = new KubeConfig();
27
- kubeconfig.loadFromCluster();
28
- const backend = new Request({ kubeconfig });
29
- this.client = new Client({ backend });
30
- }
31
- else {
32
- // configures the client from .kube/config file
33
- this.client = new Client({ version: '1.13' });
34
- }
35
- }
36
- /**
37
- * init() Must be called after creating object.
38
- * @return {Promise} [description]
39
- */
40
- async init() {
41
- try {
42
- await this.client.loadSpec();
43
- }
44
- catch (err) {
45
- const error = new TSError(err, {
46
- reason: 'Failure calling k8s loadSpec'
47
- });
48
- throw error;
49
- }
50
- }
51
- /**
52
- * Returns the k8s NamespaceList object
53
- * @return {Promise} [description]
54
- */
55
- async getNamespaces() {
56
- let namespaces;
57
- try {
58
- namespaces = await pRetry(() => this.client
59
- .api.v1.namespaces.get(), getRetryConfig());
60
- }
61
- catch (err) {
62
- const error = new TSError(err, {
63
- reason: 'Failure getting in namespaces'
64
- });
65
- throw error;
66
- }
67
- return namespaces.body;
68
- }
69
- /**
70
- * Rerturns the first pod matching the provided selector after it has
71
- * entered the `Running` state.
72
- *
73
- * TODO: Make more generic to search for different statuses
74
- *
75
- * NOTE: If your selector will return multiple pods, this method probably
76
- * won't work for you.
77
- * @param {String} selector kubernetes selector, like 'controller-uid=XXX'
78
- * @param {String} ns namespace to search, this will override the default
79
- * @param {Number} timeout time, in ms, to wait for pod to start
80
- * @return {Object} pod
81
- *
82
- * TODO: Should this use the cluster state that gets polled periodically,
83
- * rather than making it's own k8s API calls
84
- */
85
- async waitForSelectedPod(selector, ns, timeout = 10000) {
86
- const namespace = ns || this.defaultNamespace;
87
- let now = Date.now();
88
- const end = now + timeout;
89
- while (true) {
90
- const result = await pRetry(() => this.client
91
- .api.v1.namespaces(namespace).pods()
92
- .get({ qs: { labelSelector: selector } }), getRetryConfig());
93
- let pod;
94
- if (typeof result !== 'undefined' && result) {
95
- // NOTE: This assumes the first pod returned.
96
- pod = get(result, 'body.items[0]');
97
- }
98
- if (typeof pod !== 'undefined' && pod) {
99
- if (get(pod, 'status.phase') === 'Running')
100
- return pod;
101
- }
102
- if (now > end)
103
- throw new Error(`Timeout waiting for pod matching: ${selector}`);
104
- this.logger.debug(`waiting for pod matching: ${selector}`);
105
- await pDelay(this.apiPollDelay);
106
- now = Date.now();
107
- }
108
- }
109
- /**
110
- * Waits for the number of pods to equal number.
111
- * @param {Number} number Number of pods to wait for, e.g.: 0, 10
112
- * @param {String} selector kubernetes selector, like 'controller-uid=XXX'
113
- * @param {String} ns namespace to search, this will override the default
114
- * @param {Number} timeout time, in ms, to wait for pod to start
115
- * @return {Array} Array of pod objects
116
- *
117
- * TODO: Should this use the cluster state that gets polled periodically,
118
- * rather than making it's own k8s API calls?
119
- */
120
- async waitForNumPods(number, selector, ns, timeout = 10000) {
121
- const namespace = ns || this.defaultNamespace;
122
- let now = Date.now();
123
- const end = now + timeout;
124
- while (true) {
125
- const result = await pRetry(() => this.client
126
- .api.v1.namespaces(namespace).pods()
127
- .get({ qs: { labelSelector: selector } }), getRetryConfig());
128
- let podList;
129
- if (typeof result !== 'undefined' && result) {
130
- podList = get(result, 'body.items');
131
- }
132
- if (typeof podList !== 'undefined' && podList) {
133
- if (podList.length === number)
134
- return podList;
135
- }
136
- const msg = `Waiting: pods matching ${selector} is ${podList.length}/${number}`;
137
- if (now > end)
138
- throw new Error(`Timeout ${msg}`);
139
- this.logger.debug(msg);
140
- await pDelay(this.apiPollDelay);
141
- now = Date.now();
142
- }
143
- }
144
- /**
145
- * returns list of k8s objects matching provided selector
146
- * @param {String} selector kubernetes selector, like 'app=teraslice'
147
- * @param {String} objType Type of k8s object to get, valid options:
148
- * 'pods', 'deployment', 'services', 'jobs', 'replicasets'
149
- * @param {String} ns namespace to search, this will override the default
150
- * @return {Object} body of k8s get response.
151
- */
152
- async list(selector, objType, ns) {
153
- const namespace = ns || this.defaultNamespace;
154
- let response;
155
- try {
156
- if (objType === 'pods') {
157
- response = await pRetry(() => this.client
158
- .api.v1.namespaces(namespace).pods()
159
- .get({ qs: { labelSelector: selector } }), getRetryConfig());
160
- }
161
- else if (objType === 'deployments') {
162
- response = await pRetry(() => this.client
163
- .apis.apps.v1.namespaces(namespace).deployments()
164
- .get({ qs: { labelSelector: selector } }), getRetryConfig());
165
- }
166
- else if (objType === 'services') {
167
- response = await pRetry(() => this.client
168
- .api.v1.namespaces(namespace).services()
169
- .get({ qs: { labelSelector: selector } }), getRetryConfig());
170
- }
171
- else if (objType === 'jobs') {
172
- response = await pRetry(() => this.client
173
- .apis.batch.v1.namespaces(namespace).jobs()
174
- .get({ qs: { labelSelector: selector } }), getRetryConfig());
175
- }
176
- else if (objType === 'replicasets') {
177
- response = await pRetry(() => this.client
178
- .apis.apps.v1.namespaces(namespace).replicasets()
179
- .get({ qs: { labelSelector: selector } }), getRetryConfig());
180
- }
181
- else {
182
- const error = new Error(`Wrong objType provided to get: ${objType}`);
183
- this.logger.error(error);
184
- return Promise.reject(error);
185
- }
186
- }
187
- catch (e) {
188
- const err = new Error(`Request k8s.list of ${objType} with selector ${selector} failed: ${e}`);
189
- this.logger.error(err);
190
- return Promise.reject(err);
191
- }
192
- if (response.statusCode >= 400) {
193
- const err = new TSError(`Problem when trying to k8s.list ${objType}`);
194
- this.logger.error(err);
195
- err.code = response.statusCode;
196
- return Promise.reject(err);
197
- }
198
- return response.body;
199
- }
200
- async nonEmptyList(selector, objType) {
201
- const jobs = await this.list(selector, objType);
202
- if (jobs.items.length === 1) {
203
- return jobs;
204
- }
205
- else if (jobs.items.length === 0) {
206
- const msg = `Teraslice ${objType} matching the following selector was not found: ${selector} (retriable)`;
207
- this.logger.warn(msg);
208
- throw new TSError(msg, { retryable: true });
209
- }
210
- else {
211
- throw new TSError(`Unexpected number of Teraslice ${objType}s matching the following selector: ${selector}`, {
212
- retryable: true
213
- });
214
- }
215
- }
216
- /**
217
- * posts manifest to k8s
218
- * @param {Object} manifest service manifest
219
- * @param {String} manifestType 'service', 'deployment', 'job'
220
- * @return {Object} body of k8s API response object
221
- */
222
- async post(manifest, manifestType) {
223
- let response;
224
- try {
225
- if (manifestType === 'service') {
226
- response = await this.client.api.v1.namespaces(this.defaultNamespace)
227
- .service.post({ body: manifest });
228
- }
229
- else if (manifestType === 'deployment') {
230
- response = await this.client.apis.apps.v1.namespaces(this.defaultNamespace)
231
- .deployments.post({ body: manifest });
232
- }
233
- else if (manifestType === 'job') {
234
- response = await this.client.apis.batch.v1.namespaces(this.defaultNamespace)
235
- .jobs.post({ body: manifest });
236
- }
237
- else {
238
- const error = new Error(`Invalid manifestType: ${manifestType}`);
239
- return Promise.reject(error);
240
- }
241
- }
242
- catch (e) {
243
- const err = new Error(`Request k8s.post of ${manifestType} with body ${JSON.stringify(manifest)} failed: ${e}`);
244
- return Promise.reject(err);
245
- }
246
- if (response.statusCode >= 400) {
247
- const err = new TSError(`Problem when trying to k8s.post ${manifestType} with body ${JSON.stringify(manifest)}`);
248
- this.logger.error(err);
249
- err.code = response.statusCode;
250
- return Promise.reject(err);
251
- }
252
- return response.body;
253
- }
254
- /**
255
- * Patches specified k8s deployment with the provided record
256
- * @param {String} record record, like 'app=teraslice'
257
- * @param {String} name Name of the deployment to patch
258
- * @return {Object} body of k8s patch response.
259
- */
260
- // TODO: I renamed this from patchDeployment to just patch because this is
261
- // the low level k8s api method, I expect to eventually change the interface
262
- // on this to require `objType` to support patching other things
263
- async patch(record, name) {
264
- let response;
265
- try {
266
- response = await pRetry(() => this.client
267
- .apis.apps.v1.namespaces(this.defaultNamespace).deployments(name)
268
- .patch({ body: record }), getRetryConfig());
269
- }
270
- catch (e) {
271
- const err = new Error(`Request k8s.patch with ${name} failed with: ${e}`);
272
- this.logger.error(err);
273
- return Promise.reject(err);
274
- }
275
- if (response.statusCode >= 400) {
276
- const err = new TSError(`Unexpected response code (${response.statusCode}), when patching ${name} with body ${JSON.stringify(record)}`);
277
- this.logger.error(err);
278
- err.code = response.statusCode;
279
- return Promise.reject(err);
280
- }
281
- return response.body;
282
- }
283
- /**
284
- * Deletes k8s object of specified objType
285
- * @param {String} name Name of the resource to delete
286
- * @param {String} objType Type of k8s object to get, valid options:
287
- * 'deployments', 'services', 'jobs', 'pods', 'replicasets'
288
- * @param {Boolean} force Forcefully delete resource by setting gracePeriodSeconds to 1
289
- * @return {Object} k8s delete response body.
290
- */
291
- async delete(name, objType, force) {
292
- if (name === undefined || name.trim() === '') {
293
- throw new Error(`Name of resource to delete must be specified. Received: "${name}".`);
294
- }
295
- let response;
296
- // To get a Job to remove the associated pods you have to
297
- // include a body like the one below with the delete request.
298
- // To force Setting gracePeriodSeconds to 1 will send a SIGKILL command to the resource
299
- const deleteOptions = {
300
- body: {
301
- apiVersion: 'v1',
302
- kind: 'DeleteOptions',
303
- propagationPolicy: 'Background'
304
- }
305
- };
306
- if (force) {
307
- deleteOptions.body.gracePeriodSeconds = 1;
308
- }
309
- const deleteWithErrorHandling = async (deleteFn) => {
310
- try {
311
- const res = await deleteFn();
312
- return res;
313
- }
314
- catch (e) {
315
- if (e.statusCode) {
316
- // 404 should be an acceptable response to a delete request, not an error
317
- if (e.statusCode === 404) {
318
- this.logger.info(`No ${objType} with name ${name} found while attempting to delete.`);
319
- return e;
320
- }
321
- if (e.statusCode >= 400) {
322
- const err = new TSError(`Unexpected response code (${e.statusCode}), when deleting name: ${name}`);
323
- this.logger.error(err);
324
- err.code = e.statusCode.toString();
325
- return Promise.reject(err);
326
- }
327
- }
328
- throw e;
329
- }
330
- };
331
- try {
332
- if (objType === 'services') {
333
- response = await pRetry(() => deleteWithErrorHandling(() => this.client
334
- .api.v1.namespaces(this.defaultNamespace).services(name)
335
- .delete(deleteOptions)), getRetryConfig());
336
- }
337
- else if (objType === 'deployments') {
338
- response = await pRetry(() => deleteWithErrorHandling(() => this.client
339
- .apis.apps.v1.namespaces(this.defaultNamespace).deployments(name)
340
- .delete(deleteOptions)), getRetryConfig());
341
- }
342
- else if (objType === 'jobs') {
343
- response = await pRetry(() => deleteWithErrorHandling(() => this.client
344
- .apis.batch.v1.namespaces(this.defaultNamespace).jobs(name)
345
- .delete(deleteOptions)), getRetryConfig());
346
- }
347
- else if (objType === 'pods') {
348
- response = await pRetry(() => deleteWithErrorHandling(() => this.client
349
- .api.v1.namespaces(this.defaultNamespace).pods(name)
350
- .delete(deleteOptions)), getRetryConfig());
351
- }
352
- else if (objType === 'replicasets') {
353
- response = await pRetry(() => deleteWithErrorHandling(() => this.client
354
- .apis.apps.v1.namespaces(this.defaultNamespace).replicasets(name)
355
- .delete(deleteOptions)), getRetryConfig());
356
- }
357
- else {
358
- throw new Error(`Invalid objType: ${objType}`);
359
- }
360
- }
361
- catch (e) {
362
- const err = new Error(`Request k8s.delete with name: ${name} failed with: ${e}`);
363
- this.logger.error(err);
364
- return Promise.reject(err);
365
- }
366
- return response.body;
367
- }
368
- /**
369
- * Delete all of Kubernetes resources related to the specified exId
370
- * @param {String} exId ID of the execution
371
- * @param {Boolean} force Forcefully stop all pod, deployment,
372
- * service, replicaset and job resources
373
- * @return {Promise}
374
- */
375
- async deleteExecution(exId, force = false) {
376
- if (!exId) {
377
- throw new Error('deleteExecution requires an executionId');
378
- }
379
- if (force) {
380
- // Order matters. If we delete a parent resource before its children it
381
- // will be marked for background deletion and then can't be force deleted.
382
- await this._deleteObjByExId(exId, 'worker', 'pods', force);
383
- await this._deleteObjByExId(exId, 'worker', 'replicasets', force);
384
- await this._deleteObjByExId(exId, 'worker', 'deployments', force);
385
- await this._deleteObjByExId(exId, 'execution_controller', 'pods', force);
386
- await this._deleteObjByExId(exId, 'execution_controller', 'services', force);
387
- }
388
- await this._deleteObjByExId(exId, 'execution_controller', 'jobs', force);
389
- }
390
- /**
391
- * Finds the k8s objects by nodeType and exId and then deletes them
392
- * @param {String} exId Execution ID
393
- * @param {String} nodeType valid Teraslice k8s node type:
394
- * 'worker', 'execution_controller'
395
- * @param {String} objType valid object type: `services`, `deployments`,
396
- * `jobs`, `pods`, `replicasets`
397
- * @param {Boolean} force Forcefully stop all resources
398
- * @return {Promise}
399
- */
400
- async _deleteObjByExId(exId, nodeType, objType, force) {
401
- let objList;
402
- const deleteResponses = [];
403
- try {
404
- objList = await this.list(`app.kubernetes.io/component=${nodeType},teraslice.terascope.io/exId=${exId}`, objType);
405
- }
406
- catch (e) {
407
- const err = new Error(`Request ${objType} list in _deleteObjByExId with app.kubernetes.io/component: ${nodeType} and exId: ${exId} failed with: ${e}`);
408
- this.logger.error(err);
409
- return Promise.reject(err);
410
- }
411
- if (isEmpty(objList.items)) {
412
- this.logger.info(`k8s._deleteObjByExId: ${exId} ${nodeType} ${objType} has already been deleted`);
413
- return Promise.resolve();
414
- }
415
- for (const obj of objList.items) {
416
- const { name, deletionTimestamp } = obj.metadata;
417
- if (!name) {
418
- const err = new Error(`Cannot delete ${objType} for ExId: ${exId} by name because it has no name`);
419
- this.logger.error(err);
420
- return Promise.reject(err);
421
- }
422
- // If deletionTimestamp is present then the resource is already terminating.
423
- // K8s will not change the grace period in this case, so force deletion is not possible
424
- if (force && deletionTimestamp) {
425
- this.logger.warn(`Cannot force delete ${name} for ExId: ${exId}. It will finish deleting gracefully by ${deletionTimestamp}`);
426
- return Promise.resolve();
427
- }
428
- this.logger.info(`k8s._deleteObjByExId: ${exId} ${nodeType} ${objType} ${force ? 'force' : ''} deleting: ${name}`);
429
- try {
430
- deleteResponses.push(await this.delete(name, objType, force));
431
- }
432
- catch (e) {
433
- const err = new Error(`Request k8s.delete in _deleteObjByExId with name: ${name} failed with: ${e}`);
434
- this.logger.error(err);
435
- return Promise.reject(err);
436
- }
437
- }
438
- return deleteResponses;
439
- }
440
- /**
441
- * Scales the k8s deployment for the specified exId to the desired number
442
- * of workers.
443
- * @param {String} exId exId of execution to scale
444
- * @param {number} numWorkers number of workers to scale by
445
- * @param {String} op Scale operation: `set`, `add`, `remove`
446
- * @return {Object} Body of patch response.
447
- */
448
- async scaleExecution(exId, numWorkers, op) {
449
- let newScale;
450
- this.logger.info(`Scaling exId: ${exId}, op: ${op}, numWorkers: ${numWorkers}`);
451
- const listResponse = await this.list(`app.kubernetes.io/component=worker,teraslice.terascope.io/exId=${exId}`, 'deployments');
452
- this.logger.debug(`k8s worker query listResponse: ${JSON.stringify(listResponse)}`);
453
- // the selector provided to list above should always result in a single
454
- // deployment in the response.
455
- // TODO: test for more than 1 and error
456
- const workerDeployment = listResponse.items[0];
457
- this.logger.info(`Current Scale for exId=${exId}: ${workerDeployment.spec.replicas}`);
458
- if (op === 'set') {
459
- newScale = numWorkers;
460
- }
461
- else if (op === 'add') {
462
- newScale = workerDeployment.spec.replicas + numWorkers;
463
- }
464
- else if (op === 'remove') {
465
- newScale = workerDeployment.spec.replicas - numWorkers;
466
- }
467
- else {
468
- throw new Error('scaleExecution only accepts the following operations: add, remove, set');
469
- }
470
- this.logger.info(`New Scale for exId=${exId}: ${newScale}`);
471
- const scalePatch = {
472
- spec: {
473
- replicas: newScale
474
- }
475
- };
476
- const patchResponseBody = await this.patch(scalePatch, workerDeployment.metadata.name);
477
- this.logger.debug(`k8s.scaleExecution patchResponseBody: ${JSON.stringify(patchResponseBody)}`);
478
- return patchResponseBody;
479
- }
480
- }
481
- //# sourceMappingURL=k8s.js.map