kubernetes-fluent-client 1.10.0 → 2.0.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.
@@ -2,172 +2,237 @@
2
2
  // SPDX-FileCopyrightText: 2023-Present The Kubernetes Fluent Client Authors
3
3
 
4
4
  import byline from "byline";
5
+ import { createHash } from "crypto";
6
+ import { EventEmitter } from "events";
5
7
  import fetch from "node-fetch";
6
8
 
7
- import { GenericClass, LogFn } from "../types";
9
+ import { GenericClass } from "../types";
8
10
  import { Filters, WatchAction, WatchPhase } from "./types";
9
11
  import { k8sCfg, pathBuilder } from "./utils";
10
12
 
11
- /**
12
- * Wrapper for the AbortController to allow the watch to be aborted externally.
13
- */
14
- export type WatchController = {
15
- /**
16
- * Abort the watch.
17
- *
18
- * @param reason optional reason for aborting the watch
19
- */
20
- abort: (reason?: string) => void;
21
- /**
22
- * Get the AbortSignal for the watch.
23
- *
24
- * @returns the AbortSignal
25
- */
26
- signal: () => AbortSignal;
27
- };
13
+ export enum WatchEvent {
14
+ /** Watch is connected successfully */
15
+ CONNECT = "connect",
16
+ /** Network error occurs */
17
+ NETWORK_ERROR = "network_error",
18
+ /** Error decoding data or running the callback */
19
+ DATA_ERROR = "data_error",
20
+ /** Reconnect is called */
21
+ RECONNECT = "reconnect",
22
+ /** Retry limit is exceeded */
23
+ GIVE_UP = "give_up",
24
+ /** Abort is called */
25
+ ABORT = "abort",
26
+ /** Resync is called */
27
+ RESYNC = "resync",
28
+ /** Data is received and decoded */
29
+ DATA = "data",
30
+ /** Bookmark is received */
31
+ BOOKMARK = "bookmark",
32
+ /** ResourceVersion is updated */
33
+ RESOURCE_VERSION = "resource_version",
34
+ /** 410 (old resource version) occurs */
35
+ OLD_RESOURCE_VERSION = "old_resource_version",
36
+ /** A reconnect is already pending */
37
+ RECONNECT_PENDING = "reconnect_pending",
38
+ }
28
39
 
29
- /**
30
- * Configuration for the watch function.
31
- */
40
+ /** Configuration for the watch function. */
32
41
  export type WatchCfg = {
33
- /**
34
- * The maximum number of times to retry the watch, the retry count is reset on success.
35
- */
42
+ /** The resource version to start the watch at, this will be updated on each event. */
43
+ resourceVersion?: string;
44
+ /** The maximum number of times to retry the watch, the retry count is reset on success. Unlimited retries if not specified. */
36
45
  retryMax?: number;
37
- /**
38
- * The delay between retries in seconds.
39
- */
46
+ /** The delay between retries in seconds. Defaults to 10 seconds. */
40
47
  retryDelaySec?: number;
41
- /**
42
- * A function to log errors.
43
- */
44
- logFn?: LogFn;
45
- /**
46
- * A function to call when the watch fails after the maximum number of retries.
47
- */
48
- retryFail?: (e: Error) => void;
48
+ /** Amount of seconds to wait before a forced-resyncing of the watch list. Defaults to 300 (5 minutes). */
49
+ resyncIntervalSec?: number;
49
50
  };
50
51
 
51
- /**
52
- * Execute a watch on the specified resource.
53
- *
54
- * @param model - the model to use for the API
55
- * @param filters - (optional) filter overrides, can also be chained
56
- * @param callback - the callback function to call when an event is received
57
- * @param watchCfg - (optional) watch configuration
58
- * @returns a WatchController to allow the watch to be aborted externally
59
- */
60
- export async function ExecWatch<T extends GenericClass>(
61
- model: T,
62
- filters: Filters,
63
- callback: WatchAction<T>,
64
- watchCfg: WatchCfg = {},
65
- ) {
66
- watchCfg.logFn?.({ model, filters, watchCfg }, "ExecWatch");
67
-
68
- // Build the path and query params for the resource, excluding the name
69
- const { opts, serverUrl } = await k8sCfg("GET");
70
- const url = pathBuilder(serverUrl, model, filters, true);
71
-
72
- // Enable the watch query param
73
- url.searchParams.set("watch", "true");
74
-
75
- // Allow bookmarks to be used for the watch
76
- url.searchParams.set("allowWatchBookmarks", "true");
77
-
78
- // If a name is specified, add it to the query params
79
- if (filters.name) {
80
- url.searchParams.set("fieldSelector", `metadata.name=${filters.name}`);
81
- }
82
-
83
- // Set the initial timeout to 15 seconds
84
- opts.timeout = 15 * 1000;
52
+ /** A wrapper around the Kubernetes watch API. */
53
+ export class Watcher<T extends GenericClass> {
54
+ // User-provided properties
55
+ #model: T;
56
+ #filters: Filters;
57
+ #callback: WatchAction<T>;
58
+ #watchCfg: WatchCfg;
85
59
 
86
- // Enable keep alive
87
- (opts.agent as unknown as { keepAlive: boolean }).keepAlive = true;
60
+ // Create a wrapped AbortController to allow the watch to be aborted externally
61
+ #abortController: AbortController;
88
62
 
89
63
  // Track the number of retries
90
- let retryCount = 0;
64
+ #retryCount = 0;
91
65
 
92
- // Set the maximum number of retries to 5 if not specified
93
- watchCfg.retryMax ??= 5;
66
+ // Create a stream to read the response body
67
+ #stream?: byline.LineStream;
94
68
 
95
- // Set the retry delay to 5 seconds if not specified
96
- watchCfg.retryDelaySec ??= 5;
69
+ // Create an EventEmitter to emit events
70
+ #events = new EventEmitter();
97
71
 
98
- // Create a throwaway AbortController to setup the wrapped AbortController
99
- let abortController: AbortController;
72
+ // Create a timer to resync the watch
73
+ #resyncTimer?: NodeJS.Timeout;
100
74
 
101
- // Create a wrapped AbortController to allow the watch to be aborted externally
102
- const abortWrapper = {} as WatchController;
75
+ // Track if a reconnect is pending
76
+ #pendingReconnect = false;
103
77
 
104
78
  /**
105
- * Bind the abort controller to the wrapper.
79
+ * Setup a Kubernetes watcher for the specified model and filters. The callback function will be called for each event received.
80
+ * The watch can be aborted by calling {@link Watcher.close} or by calling abort() on the AbortController returned by {@link Watcher.start}.
81
+ *
82
+ *
83
+ * Kubernetes API docs: {@link https://kubernetes.io/docs/reference/using-api/api-concepts/#efficient-detection-of-changes}
84
+ *
85
+ * @param model - the model to use for the API
86
+ * @param filters - (optional) filter overrides, can also be chained
87
+ * @param callback - the callback function to call when an event is received
88
+ * @param watchCfg - (optional) watch configuration
106
89
  */
107
- function bindAbortController() {
90
+ constructor(model: T, filters: Filters, callback: WatchAction<T>, watchCfg: WatchCfg = {}) {
91
+ // Set the retry delay to 10 seconds if not specified
92
+ watchCfg.retryDelaySec ??= 10;
93
+
94
+ // Set the resync interval to 5 minutes if not specified
95
+ watchCfg.resyncIntervalSec ??= 300;
96
+
97
+ // Bind class properties
98
+ this.#model = model;
99
+ this.#filters = filters;
100
+ this.#callback = callback;
101
+ this.#watchCfg = watchCfg;
102
+
108
103
  // Create a new AbortController
109
- abortController = new AbortController();
104
+ this.#abortController = new AbortController();
105
+ }
106
+
107
+ /**
108
+ * Start the watch.
109
+ *
110
+ * @returns The AbortController for the watch.
111
+ */
112
+ public async start(): Promise<AbortController> {
113
+ await this.#runner();
114
+ return this.#abortController;
115
+ }
110
116
 
111
- // Update the abort wrapper
112
- abortWrapper.abort = reason => abortController.abort(reason);
113
- abortWrapper.signal = () => abortController.signal;
117
+ /** Close the watch. Also available on the AbortController returned by {@link Watcher.start}. */
118
+ public close() {
119
+ clearTimeout(this.#resyncTimer);
120
+ this.#cleanup();
121
+ this.#abortController.abort();
122
+ }
114
123
 
115
- // Add the abort signal to the request options
116
- opts.signal = abortController.signal;
124
+ /**
125
+ * Get a unique ID for the watch based on the model and filters.
126
+ * This is useful for caching the watch data or resource versions.
127
+ *
128
+ * @returns the watch CacheID
129
+ */
130
+ public getCacheID() {
131
+ // Build the URL, we don't care about the server URL or resourceVersion
132
+ const url = pathBuilder("https://ignore", this.#model, this.#filters, false);
133
+
134
+ // Hash and truncate the ID to 10 characters, cache the result
135
+ return createHash("sha224")
136
+ .update(url.pathname + url.search)
137
+ .digest("hex")
138
+ .substring(0, 10);
139
+ }
140
+
141
+ /**
142
+ * Get the current resource version.
143
+ *
144
+ * @returns the current resource version
145
+ */
146
+ public get resourceVersion() {
147
+ return this.#watchCfg.resourceVersion;
148
+ }
149
+
150
+ /**
151
+ * Set the current resource version.
152
+ *
153
+ * @param resourceVersion - the new resource version
154
+ */
155
+ public set resourceVersion(resourceVersion: string | undefined) {
156
+ this.#watchCfg.resourceVersion = resourceVersion;
157
+ }
158
+
159
+ /**
160
+ * Subscribe to watch events. This is an EventEmitter that emits the following events:
161
+ *
162
+ * Use {@link WatchEvent} for the event names.
163
+ *
164
+ * @returns an EventEmitter
165
+ */
166
+ public get events(): EventEmitter {
167
+ return this.#events;
117
168
  }
118
169
 
119
170
  /**
120
- * The main watch runner. This will run until the process is terminated or the watch is aborted.
171
+ * Build the URL and request options for the watch.
172
+ *
173
+ * @returns the URL and request options
121
174
  */
122
- async function runner() {
123
- let doneCalled = false;
175
+ #buildURL = async () => {
176
+ // Build the path and query params for the resource, excluding the name
177
+ const { opts, serverUrl } = await k8sCfg("GET");
178
+ const url = pathBuilder(serverUrl, this.#model, this.#filters, true);
124
179
 
125
- bindAbortController();
180
+ // Enable the watch query param
181
+ url.searchParams.set("watch", "true");
126
182
 
127
- // Create a stream to read the response body
128
- const stream = byline.createStream();
183
+ // If a name is specified, add it to the query params
184
+ if (this.#filters.name) {
185
+ url.searchParams.set("fieldSelector", `metadata.name=${this.#filters.name}`);
186
+ }
129
187
 
130
- const onError = (err: Error) => {
131
- stream.removeAllListeners();
188
+ // If a resource version is specified, add it to the query params
189
+ if (this.#watchCfg.resourceVersion) {
190
+ url.searchParams.set("resourceVersion", this.#watchCfg.resourceVersion);
191
+ }
132
192
 
133
- if (!doneCalled) {
134
- doneCalled = true;
193
+ // Enable watch bookmarks
194
+ url.searchParams.set("allowWatchBookmarks", "true");
135
195
 
136
- // If the error is not an AbortError, reload the watch
137
- if (err.name !== "AbortError") {
138
- watchCfg.logFn?.(err, "stream error");
139
- void reload(err);
140
- } else {
141
- watchCfg.logFn?.("watch aborted via WatchController.abort()");
142
- }
143
- }
144
- };
196
+ // Add the abort signal to the request options
197
+ opts.signal = this.#abortController.signal;
145
198
 
146
- // Cleanup the stream listeners
147
- const cleanup = () => {
148
- if (!doneCalled) {
149
- doneCalled = true;
150
- stream.removeAllListeners();
151
- }
152
- };
199
+ return { opts, url };
200
+ };
153
201
 
202
+ /** Run the watch. */
203
+ #runner = async () => {
154
204
  try {
205
+ // Build the URL and request options
206
+ const { opts, url } = await this.#buildURL();
207
+
208
+ // Create a stream to read the response body
209
+ this.#stream = byline.createStream();
210
+
211
+ // Bind the stream events
212
+ this.#stream.on("error", this.#errHandler);
213
+ this.#stream.on("close", this.#cleanup);
214
+ this.#stream.on("finish", this.#cleanup);
215
+
155
216
  // Make the actual request
156
217
  const response = await fetch(url, { ...opts });
157
218
 
219
+ // Reset the pending reconnect flag
220
+ this.#pendingReconnect = false;
221
+
222
+ // Reset the resync timer
223
+ void this.#scheduleResync();
224
+
158
225
  // If the request is successful, start listening for events
159
226
  if (response.ok) {
227
+ this.#events.emit(WatchEvent.CONNECT);
228
+
160
229
  const { body } = response;
161
230
 
162
231
  // Reset the retry count
163
- retryCount = 0;
164
-
165
- stream.on("error", onError);
166
- stream.on("close", cleanup);
167
- stream.on("finish", cleanup);
232
+ this.#retryCount = 0;
168
233
 
169
234
  // Listen for events and call the callback function
170
- stream.on("data", line => {
235
+ this.#stream.on("data", async line => {
171
236
  try {
172
237
  // Parse the event payload
173
238
  const { object: payload, type: phase } = JSON.parse(line) as {
@@ -175,53 +240,157 @@ export async function ExecWatch<T extends GenericClass>(
175
240
  object: InstanceType<T>;
176
241
  };
177
242
 
178
- // Call the callback function with the parsed payload
179
- void callback(payload, phase as WatchPhase);
243
+ void this.#scheduleResync();
244
+
245
+ // If the watch is too old, remove the resourceVersion and reload the watch
246
+ if (phase === WatchPhase.Error && payload.code === 410) {
247
+ throw {
248
+ name: "TooOld",
249
+ message: this.#watchCfg.resourceVersion!,
250
+ };
251
+ }
252
+
253
+ // If the event is a bookmark, emit the event and skip the callback
254
+ if (phase === WatchPhase.Bookmark) {
255
+ this.#events.emit(WatchEvent.BOOKMARK, payload);
256
+ } else {
257
+ this.#events.emit(WatchEvent.DATA, payload, phase);
258
+
259
+ // Call the callback function with the parsed payload
260
+ await this.#callback(payload, phase as WatchPhase);
261
+ }
262
+
263
+ // Update the resource version if the callback was successful
264
+ this.#setResourceVersion(payload.metadata.resourceVersion);
180
265
  } catch (err) {
181
- watchCfg.logFn?.(err, "watch callback error");
266
+ if (err.name === "TooOld") {
267
+ // Prevent any body events from firing
268
+ body.removeAllListeners();
269
+
270
+ // Reload the watch
271
+ void this.#errHandler(err);
272
+ return;
273
+ }
274
+
275
+ this.#events.emit(WatchEvent.DATA_ERROR, err);
182
276
  }
183
277
  });
184
278
 
185
- body.on("error", onError);
186
- body.on("close", cleanup);
187
- body.on("finish", cleanup);
279
+ // Bind the body events
280
+ body.on("error", this.#errHandler);
281
+ body.on("close", this.#cleanup);
282
+ body.on("finish", this.#cleanup);
188
283
 
189
284
  // Pipe the response body to the stream
190
- body.pipe(stream);
285
+ body.pipe(this.#stream);
191
286
  } else {
192
- throw new Error(`watch failed: ${response.status} ${response.statusText}`);
287
+ throw new Error(`watch connect failed: ${response.status} ${response.statusText}`);
193
288
  }
194
289
  } catch (e) {
195
- onError(e);
290
+ void this.#errHandler(e);
196
291
  }
292
+ };
293
+
294
+ /**
295
+ * Resync the watch.
296
+ *
297
+ * @returns the error handler
298
+ */
299
+ #resync = () =>
300
+ this.#errHandler({
301
+ name: "Resync",
302
+ message: "Resync triggered by resyncIntervalSec",
303
+ });
304
+
305
+ /** Clear the resync timer and schedule a new one. */
306
+ #scheduleResync = async () => {
307
+ clearTimeout(this.#resyncTimer);
308
+ this.#resyncTimer = setTimeout(this.#resync, this.#watchCfg.resyncIntervalSec! * 1000);
309
+ };
310
+
311
+ /**
312
+ * Update the resource version.
313
+ *
314
+ * @param resourceVersion - the new resource version
315
+ */
316
+ #setResourceVersion = (resourceVersion?: string) => {
317
+ this.#watchCfg.resourceVersion = resourceVersion;
318
+ this.#events.emit(WatchEvent.RESOURCE_VERSION, resourceVersion);
319
+ };
197
320
 
198
- /**
199
- * Reload the watch.
200
- *
201
- * @param e - the error that caused the reload
202
- */
203
- async function reload(e: Error) {
204
- // If there are more attempts, retry the watch
205
- if (watchCfg.retryMax! > retryCount) {
206
- retryCount++;
321
+ /**
322
+ * Reload the watch after an error.
323
+ *
324
+ * @param err - the error that occurred
325
+ */
326
+ #reconnect = async (err: Error) => {
327
+ // If there are more attempts, retry the watch (undefined is unlimited retries)
328
+ if (this.#watchCfg.retryMax === undefined || this.#watchCfg.retryMax > this.#retryCount) {
329
+ // Sleep for the specified delay, but check every 500ms if the watch has been aborted
330
+ let delay = this.#watchCfg.retryDelaySec! * 1000;
331
+ while (delay > 0) {
332
+ if (this.#abortController.signal.aborted) {
333
+ return;
334
+ }
335
+ delay -= 500;
336
+ await new Promise(resolve => setTimeout(resolve, 500));
337
+ }
207
338
 
208
- watchCfg.logFn?.(`retrying watch ${retryCount}/${watchCfg.retryMax}`);
339
+ this.#retryCount++;
340
+ this.#events.emit(WatchEvent.RECONNECT, err, this.#retryCount);
209
341
 
210
- // Sleep for the specified delay or 5 seconds
211
- await new Promise(r => setTimeout(r, watchCfg.retryDelaySec! * 1000));
342
+ if (this.#pendingReconnect) {
343
+ this.#events.emit(WatchEvent.RECONNECT_PENDING);
344
+ } else {
345
+ this.#pendingReconnect = true;
346
+ this.#cleanup();
212
347
 
213
348
  // Retry the watch after the delay
214
- await runner();
215
- } else {
216
- // Otherwise, call the finally function if it exists
217
- if (watchCfg.retryFail) {
218
- watchCfg.retryFail(e);
219
- }
349
+ await this.#runner();
220
350
  }
351
+ } else {
352
+ // Otherwise, call the finally function if it exists
353
+ this.#events.emit(WatchEvent.GIVE_UP, err);
354
+ this.close();
221
355
  }
222
- }
356
+ };
223
357
 
224
- await runner();
358
+ /**
359
+ * Handle errors from the stream.
360
+ *
361
+ * @param err - the error that occurred
362
+ */
363
+ #errHandler = async (err: Error) => {
364
+ switch (err.name) {
365
+ case "AbortError":
366
+ clearTimeout(this.#resyncTimer);
367
+ this.#cleanup();
368
+ this.#events.emit(WatchEvent.ABORT, err);
369
+ return;
370
+
371
+ case "TooOld":
372
+ // Purge the resource version if it is too old
373
+ this.#setResourceVersion(undefined);
374
+ this.#events.emit(WatchEvent.OLD_RESOURCE_VERSION, err.message);
375
+ break;
376
+
377
+ case "Resync":
378
+ this.#events.emit(WatchEvent.RESYNC, err);
379
+ break;
380
+
381
+ default:
382
+ this.#events.emit(WatchEvent.NETWORK_ERROR, err);
383
+ break;
384
+ }
385
+
386
+ await this.#reconnect(err);
387
+ };
225
388
 
226
- return abortWrapper;
389
+ /** Cleanup the stream and listeners. */
390
+ #cleanup = () => {
391
+ if (this.#stream) {
392
+ this.#stream.removeAllListeners();
393
+ this.#stream.destroy();
394
+ }
395
+ };
227
396
  }
package/src/index.ts CHANGED
@@ -15,6 +15,9 @@ export { fetch } from "./fetch";
15
15
  // Export the HTTP status codes
16
16
  export { StatusCodes as fetchStatus } from "http-status-codes";
17
17
 
18
+ // Export the Watch Config and Event types
19
+ export { WatchCfg, WatchEvent } from "./fluent/watch";
20
+
18
21
  // Export the fluent API entrypoint
19
22
  export { K8s } from "./fluent";
20
23