kubernetes-fluent-client 2.5.1 → 2.6.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.
@@ -6,7 +6,8 @@ import { createHash } from "crypto";
6
6
  import { EventEmitter } from "events";
7
7
  import fetch from "node-fetch";
8
8
 
9
- import { GenericClass } from "../types";
9
+ import { fetch as wrappedFetch } from "../fetch";
10
+ import { GenericClass, KubernetesListObject } from "../types";
10
11
  import { Filters, WatchAction, WatchPhase } from "./types";
11
12
  import { k8sCfg, pathBuilder } from "./utils";
12
13
 
@@ -23,34 +24,33 @@ export enum WatchEvent {
23
24
  GIVE_UP = "give_up",
24
25
  /** Abort is called */
25
26
  ABORT = "abort",
26
- /** Resync is called */
27
- RESYNC = "resync",
28
27
  /** Data is received and decoded */
29
28
  DATA = "data",
30
- /** Bookmark is received */
31
- BOOKMARK = "bookmark",
32
- /** ResourceVersion is updated */
33
- RESOURCE_VERSION = "resource_version",
34
29
  /** 410 (old resource version) occurs */
35
30
  OLD_RESOURCE_VERSION = "old_resource_version",
36
31
  /** A reconnect is already pending */
37
32
  RECONNECT_PENDING = "reconnect_pending",
33
+ /** Resource list operation run */
34
+ LIST = "list",
35
+ /** List operation error */
36
+ LIST_ERROR = "list_error",
38
37
  }
39
38
 
40
39
  /** Configuration for the watch function. */
41
40
  export type WatchCfg = {
42
- /** Whether to allow watch bookmarks. */
43
- allowWatchBookmarks?: boolean;
44
- /** The resource version to start the watch at, this will be updated on each event. */
45
- resourceVersion?: string;
46
41
  /** The maximum number of times to retry the watch, the retry count is reset on success. Unlimited retries if not specified. */
47
42
  retryMax?: number;
48
- /** The delay between retries in seconds. Defaults to 10 seconds. */
43
+ /** Seconds between each retry check. Defaults to 5. */
49
44
  retryDelaySec?: number;
45
+ /** Amount of seconds to wait before relisting the watch list. Defaults to 600 (10 minutes). */
46
+ relistIntervalSec?: number;
50
47
  /** Amount of seconds to wait before a forced-resyncing of the watch list. Defaults to 300 (5 minutes). */
51
48
  resyncIntervalSec?: number;
52
49
  };
53
50
 
51
+ const NONE = 50;
52
+ const OVERRIDE = 100;
53
+
54
54
  /** A wrapper around the Kubernetes watch API. */
55
55
  export class Watcher<T extends GenericClass> {
56
56
  // User-provided properties
@@ -59,6 +59,10 @@ export class Watcher<T extends GenericClass> {
59
59
  #callback: WatchAction<T>;
60
60
  #watchCfg: WatchCfg;
61
61
 
62
+ // Track the last time data was received
63
+ #lastSeenTime = NONE;
64
+ #lastSeenLimit: number;
65
+
62
66
  // Create a wrapped AbortController to allow the watch to be aborted externally
63
67
  #abortController: AbortController;
64
68
 
@@ -71,12 +75,21 @@ export class Watcher<T extends GenericClass> {
71
75
  // Create an EventEmitter to emit events
72
76
  #events = new EventEmitter();
73
77
 
78
+ // Create a timer to relist the watch
79
+ $relistTimer?: NodeJS.Timeout;
80
+
74
81
  // Create a timer to resync the watch
75
82
  #resyncTimer?: NodeJS.Timeout;
76
83
 
77
84
  // Track if a reconnect is pending
78
85
  #pendingReconnect = false;
79
86
 
87
+ // The resource version to start the watch at, this will be updated after the list operation.
88
+ #resourceVersion?: string;
89
+
90
+ // Track the list of items in the cache
91
+ #cache = new Map<string, InstanceType<T>>();
92
+
80
93
  /**
81
94
  * Setup a Kubernetes watcher for the specified model and filters. The callback function will be called for each event received.
82
95
  * The watch can be aborted by calling {@link Watcher.close} or by calling abort() on the AbortController returned by {@link Watcher.start}.
@@ -90,11 +103,26 @@ export class Watcher<T extends GenericClass> {
90
103
  * @param watchCfg - (optional) watch configuration
91
104
  */
92
105
  constructor(model: T, filters: Filters, callback: WatchAction<T>, watchCfg: WatchCfg = {}) {
93
- // Set the retry delay to 10 seconds if not specified
94
- watchCfg.retryDelaySec ??= 10;
106
+ // Set the retry delay to 5 seconds if not specified
107
+ watchCfg.retryDelaySec ??= 5;
108
+
109
+ // Set the relist interval to 30 minutes if not specified
110
+ watchCfg.relistIntervalSec ??= 1800;
95
111
 
96
- // Set the resync interval to 5 minutes if not specified
97
- watchCfg.resyncIntervalSec ??= 300;
112
+ // Set the resync interval to 10 minutes if not specified
113
+ watchCfg.resyncIntervalSec ??= 600;
114
+
115
+ // Set the last seen limit to the resync interval
116
+ this.#lastSeenLimit = watchCfg.resyncIntervalSec * 1000;
117
+
118
+ // Add random jitter to the relist/resync intervals (up to 1 second)
119
+ const jitter = Math.floor(Math.random() * 1000);
120
+
121
+ // Check every relist interval for cache staleness
122
+ this.$relistTimer = setInterval(this.#list, watchCfg.relistIntervalSec * 1000 + jitter);
123
+
124
+ // Rebuild the watch every retry delay interval
125
+ this.#resyncTimer = setInterval(this.#checkResync, watchCfg.retryDelaySec * 1000 + jitter);
98
126
 
99
127
  // Bind class properties
100
128
  this.#model = model;
@@ -112,14 +140,15 @@ export class Watcher<T extends GenericClass> {
112
140
  * @returns The AbortController for the watch.
113
141
  */
114
142
  public async start(): Promise<AbortController> {
115
- await this.#runner();
143
+ await this.#watch();
116
144
  return this.#abortController;
117
145
  }
118
146
 
119
147
  /** Close the watch. Also available on the AbortController returned by {@link Watcher.start}. */
120
148
  public close() {
121
- clearTimeout(this.#resyncTimer);
122
- this.#cleanup();
149
+ clearInterval(this.$relistTimer);
150
+ clearInterval(this.#resyncTimer);
151
+ this.#streamCleanup();
123
152
  this.#abortController.abort();
124
153
  }
125
154
 
@@ -140,24 +169,6 @@ export class Watcher<T extends GenericClass> {
140
169
  .substring(0, 10);
141
170
  }
142
171
 
143
- /**
144
- * Get the current resource version.
145
- *
146
- * @returns the current resource version
147
- */
148
- public get resourceVersion() {
149
- return this.#watchCfg.resourceVersion;
150
- }
151
-
152
- /**
153
- * Set the current resource version.
154
- *
155
- * @param resourceVersion - the new resource version
156
- */
157
- public set resourceVersion(resourceVersion: string | undefined) {
158
- this.#watchCfg.resourceVersion = resourceVersion;
159
- }
160
-
161
172
  /**
162
173
  * Subscribe to watch events. This is an EventEmitter that emits the following events:
163
174
  *
@@ -172,16 +183,26 @@ export class Watcher<T extends GenericClass> {
172
183
  /**
173
184
  * Build the URL and request options for the watch.
174
185
  *
186
+ * @param isWatch - whether the request is for a watch operation
187
+ * @param resourceVersion - the resource version to use for the watch
188
+ * @param continueToken - the continue token for the watch
189
+ *
175
190
  * @returns the URL and request options
176
191
  */
177
- #buildURL = async () => {
192
+ #buildURL = async (isWatch: boolean, resourceVersion?: string, continueToken?: string) => {
178
193
  // Build the path and query params for the resource, excluding the name
179
194
  const { opts, serverUrl } = await k8sCfg("GET");
180
195
 
181
196
  const url = pathBuilder(serverUrl, this.#model, this.#filters, true);
182
197
 
183
198
  // Enable the watch query param
184
- url.searchParams.set("watch", "true");
199
+ if (isWatch) {
200
+ url.searchParams.set("watch", "true");
201
+ }
202
+
203
+ if (continueToken) {
204
+ url.searchParams.set("continue", continueToken);
205
+ }
185
206
 
186
207
  // If a name is specified, add it to the query params
187
208
  if (this.#filters.name) {
@@ -189,35 +210,144 @@ export class Watcher<T extends GenericClass> {
189
210
  }
190
211
 
191
212
  // If a resource version is specified, add it to the query params
192
- if (this.#watchCfg.resourceVersion) {
193
- url.searchParams.set("resourceVersion", this.#watchCfg.resourceVersion);
213
+ if (resourceVersion) {
214
+ url.searchParams.set("resourceVersion", resourceVersion);
194
215
  }
195
216
 
196
- // Enable watch bookmarks
197
- url.searchParams.set(
198
- "allowWatchBookmarks",
199
- this.#watchCfg.allowWatchBookmarks ? `${this.#watchCfg.allowWatchBookmarks}` : "true",
200
- );
201
-
202
217
  // Add the abort signal to the request options
203
218
  opts.signal = this.#abortController.signal;
204
219
 
205
220
  return { opts, url };
206
221
  };
207
222
 
208
- /** Run the watch. */
209
- #runner = async () => {
223
+ /**
224
+ * Retrieve the list of resources and process the events.
225
+ *
226
+ * @param continueToken - the continue token for the list
227
+ * @param removedItems - the list of items that have been removed
228
+ */
229
+ #list = async (continueToken?: string, removedItems?: Map<string, InstanceType<T>>) => {
210
230
  try {
231
+ const { opts, url } = await this.#buildURL(false, undefined, continueToken);
232
+
233
+ // Make the request to list the resources
234
+ const response = await wrappedFetch<KubernetesListObject<InstanceType<T>>>(url, opts);
235
+ const list = response.data;
236
+
237
+ // If the request fails, emit an error event and return
238
+ if (!response.ok) {
239
+ this.#events.emit(
240
+ WatchEvent.LIST_ERROR,
241
+ new Error(`list failed: ${response.status} ${response.statusText}`),
242
+ );
243
+
244
+ return;
245
+ }
246
+
247
+ // Gross hack, thanks upstream library :<
248
+ if ((list.metadata as { continue?: string }).continue) {
249
+ continueToken = (list.metadata as { continue?: string }).continue;
250
+ }
251
+
252
+ // Emit the list event
253
+ this.#events.emit(WatchEvent.LIST, list);
254
+
255
+ // Update the resource version from the list metadata
256
+ this.#resourceVersion = list.metadata?.resourceVersion;
257
+
258
+ // If removed items are not provided, clone the cache
259
+ removedItems = removedItems || new Map(this.#cache.entries());
260
+
261
+ // Process each item in the list
262
+ for (const item of list.items || []) {
263
+ const { uid } = item.metadata;
264
+
265
+ // Remove the item from the removed items list
266
+ const alreadyExists = removedItems.delete(uid);
267
+
268
+ // If the item does not exist, it is new and should be added
269
+ if (!alreadyExists) {
270
+ // Send added event. Use void here because we don't care about the result (no consequences here if it fails)
271
+ void this.#process(item, WatchPhase.Added);
272
+ continue;
273
+ }
274
+
275
+ // Check if the resource version has changed for items that already exist
276
+ const cachedRV = parseInt(this.#cache.get(uid)?.metadata?.resourceVersion);
277
+ const itemRV = parseInt(item.metadata.resourceVersion);
278
+
279
+ // Check if the resource version is newer than the cached version
280
+ if (itemRV > cachedRV) {
281
+ // Send a modified event if the resource version has changed
282
+ void this.#process(item, WatchPhase.Modified);
283
+ }
284
+ }
285
+
286
+ // If there is a continue token, call the list function again with the same removed items
287
+ if (continueToken) {
288
+ // If there is a continue token, call the list function again with the same removed items
289
+ // @todo: using all voids here is important for freshness, but is naive with regard to API load & pod resources
290
+ await this.#list(continueToken, removedItems);
291
+ } else {
292
+ // Otherwise, process the removed items
293
+ for (const item of removedItems.values()) {
294
+ void this.#process(item, WatchPhase.Deleted);
295
+ }
296
+ }
297
+ } catch (err) {
298
+ this.#events.emit(WatchEvent.LIST_ERROR, err);
299
+ }
300
+ };
301
+
302
+ /**
303
+ * Process the event payload.
304
+ *
305
+ * @param payload - the event payload
306
+ * @param phase - the event phase
307
+ */
308
+ #process = async (payload: InstanceType<T>, phase: WatchPhase) => {
309
+ try {
310
+ switch (phase) {
311
+ // If the event is added or modified, update the cache
312
+ case WatchPhase.Added:
313
+ case WatchPhase.Modified:
314
+ this.#cache.set(payload.metadata.uid, payload);
315
+ break;
316
+
317
+ // If the event is deleted, remove the item from the cache
318
+ case WatchPhase.Deleted:
319
+ this.#cache.delete(payload.metadata.uid);
320
+ break;
321
+ }
322
+
323
+ // Emit the data event
324
+ this.#events.emit(WatchEvent.DATA, payload, phase);
325
+
326
+ // Call the callback function with the parsed payload
327
+ await this.#callback(payload, phase);
328
+ } catch (err) {
329
+ this.#events.emit(WatchEvent.DATA_ERROR, err);
330
+ }
331
+ };
332
+
333
+ /**
334
+ * Watch for changes to the resource.
335
+ */
336
+ #watch = async () => {
337
+ try {
338
+ // Start with a list operation
339
+ await this.#list();
340
+
211
341
  // Build the URL and request options
212
- const { opts, url } = await this.#buildURL();
342
+ const { opts, url } = await this.#buildURL(true, this.#resourceVersion);
213
343
 
214
344
  // Create a stream to read the response body
215
345
  this.#stream = byline.createStream();
216
346
 
217
347
  // Bind the stream events
218
348
  this.#stream.on("error", this.#errHandler);
219
- this.#stream.on("close", this.#cleanup);
220
- this.#stream.on("finish", this.#cleanup);
349
+ this.#stream.on("close", this.#streamCleanup);
350
+ this.#stream.on("finish", this.#streamCleanup);
221
351
 
222
352
  // Make the actual request
223
353
  const response = await fetch(url, { ...opts });
@@ -225,9 +355,6 @@ export class Watcher<T extends GenericClass> {
225
355
  // Reset the pending reconnect flag
226
356
  this.#pendingReconnect = false;
227
357
 
228
- // Reset the resync timer
229
- void this.#scheduleResync();
230
-
231
358
  // If the request is successful, start listening for events
232
359
  if (response.ok) {
233
360
  this.#events.emit(WatchEvent.CONNECT, url.pathname);
@@ -246,28 +373,19 @@ export class Watcher<T extends GenericClass> {
246
373
  object: InstanceType<T>;
247
374
  };
248
375
 
249
- void this.#scheduleResync();
376
+ // Update the last seen time
377
+ this.#lastSeenTime = Date.now();
250
378
 
251
379
  // If the watch is too old, remove the resourceVersion and reload the watch
252
380
  if (phase === WatchPhase.Error && payload.code === 410) {
253
381
  throw {
254
382
  name: "TooOld",
255
- message: this.#watchCfg.resourceVersion!,
383
+ message: this.#resourceVersion!,
256
384
  };
257
385
  }
258
386
 
259
- // If the event is a bookmark, emit the event and skip the callback
260
- if (phase === WatchPhase.Bookmark) {
261
- this.#events.emit(WatchEvent.BOOKMARK, payload);
262
- } else {
263
- this.#events.emit(WatchEvent.DATA, payload, phase);
264
-
265
- // Call the callback function with the parsed payload
266
- await this.#callback(payload, phase as WatchPhase);
267
- }
268
-
269
- // Update the resource version if the callback was successful
270
- this.#setResourceVersion(payload.metadata.resourceVersion);
387
+ // Process the event payload, do not update the resource version as that is handled by the list operation
388
+ await this.#process(payload, phase);
271
389
  } catch (err) {
272
390
  if (err.name === "TooOld") {
273
391
  // Prevent any body events from firing
@@ -277,14 +395,15 @@ export class Watcher<T extends GenericClass> {
277
395
  void this.#errHandler(err);
278
396
  return;
279
397
  }
398
+
280
399
  this.#events.emit(WatchEvent.DATA_ERROR, err);
281
400
  }
282
401
  });
283
402
 
284
403
  // Bind the body events
285
404
  body.on("error", this.#errHandler);
286
- body.on("close", this.#cleanup);
287
- body.on("finish", this.#cleanup);
405
+ body.on("close", this.#streamCleanup);
406
+ body.on("finish", this.#streamCleanup);
288
407
 
289
408
  // Pipe the response body to the stream
290
409
  body.pipe(this.#stream);
@@ -296,68 +415,43 @@ export class Watcher<T extends GenericClass> {
296
415
  }
297
416
  };
298
417
 
299
- /**
300
- * Resync the watch.
301
- *
302
- * @returns the error handler
303
- */
304
- #resync = () =>
305
- this.#errHandler({
306
- name: "Resync",
307
- message: "Resync triggered by resyncIntervalSec",
308
- });
309
-
310
418
  /** Clear the resync timer and schedule a new one. */
311
- #scheduleResync = async () => {
312
- clearTimeout(this.#resyncTimer);
313
- this.#resyncTimer = setTimeout(this.#resync, this.#watchCfg.resyncIntervalSec! * 1000);
314
- };
419
+ #checkResync = () => {
420
+ // Ignore if the last seen time is not set
421
+ if (this.#lastSeenTime === NONE) {
422
+ return;
423
+ }
315
424
 
316
- /**
317
- * Update the resource version.
318
- *
319
- * @param resourceVersion - the new resource version
320
- */
321
- #setResourceVersion = (resourceVersion?: string) => {
322
- this.#watchCfg.resourceVersion = resourceVersion;
323
- this.#events.emit(WatchEvent.RESOURCE_VERSION, resourceVersion);
324
- };
425
+ const now = Date.now();
325
426
 
326
- /**
327
- * Reload the watch after an error.
328
- *
329
- * @param err - the error that occurred
330
- */
331
- #reconnect = async (err: Error) => {
332
- // If there are more attempts, retry the watch (undefined is unlimited retries)
333
- if (this.#watchCfg.retryMax === undefined || this.#watchCfg.retryMax > this.#retryCount) {
334
- // Sleep for the specified delay, but check every 500ms if the watch has been aborted
335
- let delay = this.#watchCfg.retryDelaySec! * 1000;
336
- while (delay > 0) {
337
- if (this.#abortController.signal.aborted) {
338
- return;
339
- }
340
- delay -= 500;
341
- await new Promise(resolve => setTimeout(resolve, 500));
342
- }
427
+ // If the last seen time is greater than the limit, trigger a resync
428
+ if (this.#lastSeenTime == OVERRIDE || now - this.#lastSeenTime > this.#lastSeenLimit) {
429
+ // Reset the last seen time to now to allow the resync to be called again in case of failure
430
+ this.#lastSeenTime = now;
343
431
 
344
- this.#retryCount++;
345
- this.#events.emit(WatchEvent.RECONNECT, err, this.#retryCount);
432
+ // If there are more attempts, retry the watch (undefined is unlimited retries)
433
+ if (this.#watchCfg.retryMax === undefined || this.#watchCfg.retryMax > this.#retryCount) {
434
+ // Increment the retry count
435
+ this.#retryCount++;
346
436
 
347
- if (this.#pendingReconnect) {
348
- // wait for the connection to be re-established
349
- this.#events.emit(WatchEvent.RECONNECT_PENDING);
350
- } else {
351
- this.#pendingReconnect = true;
352
- this.#cleanup();
437
+ if (this.#pendingReconnect) {
438
+ // wait for the connection to be re-established
439
+ this.#events.emit(WatchEvent.RECONNECT_PENDING);
440
+ } else {
441
+ this.#pendingReconnect = true;
442
+ this.#events.emit(WatchEvent.RECONNECT, this.#retryCount);
443
+ this.#streamCleanup();
353
444
 
354
- // Retry the watch after the delay
355
- await this.#runner();
445
+ void this.#watch();
446
+ }
447
+ } else {
448
+ // Otherwise, call the finally function if it exists
449
+ this.#events.emit(
450
+ WatchEvent.GIVE_UP,
451
+ new Error(`Retry limit (${this.#watchCfg.retryMax}) exceeded, giving up`),
452
+ );
453
+ this.close();
356
454
  }
357
- } else {
358
- // Otherwise, call the finally function if it exists
359
- this.#events.emit(WatchEvent.GIVE_UP, err);
360
- this.close();
361
455
  }
362
456
  };
363
457
 
@@ -369,31 +463,29 @@ export class Watcher<T extends GenericClass> {
369
463
  #errHandler = async (err: Error) => {
370
464
  switch (err.name) {
371
465
  case "AbortError":
372
- clearTimeout(this.#resyncTimer);
373
- this.#cleanup();
466
+ clearInterval(this.$relistTimer);
467
+ clearInterval(this.#resyncTimer);
468
+ this.#streamCleanup();
374
469
  this.#events.emit(WatchEvent.ABORT, err);
375
470
  return;
376
471
 
377
472
  case "TooOld":
378
473
  // Purge the resource version if it is too old
379
- this.#setResourceVersion(undefined);
474
+ this.#resourceVersion = undefined;
380
475
  this.#events.emit(WatchEvent.OLD_RESOURCE_VERSION, err.message);
381
476
  break;
382
477
 
383
- case "Resync":
384
- this.#events.emit(WatchEvent.RESYNC, err);
385
- break;
386
-
387
478
  default:
388
479
  this.#events.emit(WatchEvent.NETWORK_ERROR, err);
389
480
  break;
390
481
  }
391
482
 
392
- await this.#reconnect(err);
483
+ // Force a resync
484
+ this.#lastSeenTime = OVERRIDE;
393
485
  };
394
486
 
395
487
  /** Cleanup the stream and listeners. */
396
- #cleanup = () => {
488
+ #streamCleanup = () => {
397
489
  if (this.#stream) {
398
490
  this.#stream.removeAllListeners();
399
491
  this.#stream.destroy();