kubernetes-fluent-client 1.10.0 → 2.0.0

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,231 @@
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
+ // Unique ID for the watch
76
+ #id?: string;
77
+ #hashedID?: string;
78
+
79
+ // Track if a reconnect is pending
80
+ #pendingReconnect = false;
103
81
 
104
82
  /**
105
- * Bind the abort controller to the wrapper.
83
+ * Setup a Kubernetes watcher for the specified model and filters. The callback function will be called for each event received.
84
+ * The watch can be aborted by calling {@link Watcher.close} or by calling abort() on the AbortController returned by {@link Watcher.start}.
85
+ *
86
+ *
87
+ * Kubernetes API docs: {@link https://kubernetes.io/docs/reference/using-api/api-concepts/#efficient-detection-of-changes}
88
+ *
89
+ * @param model - the model to use for the API
90
+ * @param filters - (optional) filter overrides, can also be chained
91
+ * @param callback - the callback function to call when an event is received
92
+ * @param watchCfg - (optional) watch configuration
106
93
  */
107
- function bindAbortController() {
94
+ constructor(model: T, filters: Filters, callback: WatchAction<T>, watchCfg: WatchCfg = {}) {
95
+ // Set the retry delay to 10 seconds if not specified
96
+ watchCfg.retryDelaySec ??= 10;
97
+
98
+ // Set the resync interval to 5 minutes if not specified
99
+ watchCfg.resyncIntervalSec ??= 300;
100
+
101
+ // Bind class properties
102
+ this.#model = model;
103
+ this.#filters = filters;
104
+ this.#callback = callback;
105
+ this.#watchCfg = watchCfg;
106
+
108
107
  // Create a new AbortController
109
- abortController = new AbortController();
108
+ this.#abortController = new AbortController();
109
+ }
110
+
111
+ /**
112
+ * Start the watch.
113
+ *
114
+ * @returns The AbortController for the watch.
115
+ */
116
+ public async start(): Promise<AbortController> {
117
+ await this.#runner();
118
+ return this.#abortController;
119
+ }
110
120
 
111
- // Update the abort wrapper
112
- abortWrapper.abort = reason => abortController.abort(reason);
113
- abortWrapper.signal = () => abortController.signal;
121
+ /** Close the watch. Also available on the AbortController returned by {@link Watcher.start}. */
122
+ public close() {
123
+ clearTimeout(this.#resyncTimer);
124
+ this.#cleanup();
125
+ this.#abortController.abort();
126
+ }
114
127
 
115
- // Add the abort signal to the request options
116
- opts.signal = abortController.signal;
128
+ /**
129
+ * Get a unique ID for the watch based on the model and filters.
130
+ * This is useful for caching the watch data or resource versions.
131
+ *
132
+ * @returns the watch ID
133
+ */
134
+ public get id() {
135
+ // The ID must exist at this point
136
+ if (!this.#id) {
137
+ throw new Error("watch not started");
138
+ }
139
+
140
+ // Hash and truncate the ID to 10 characters, cache the result
141
+ if (!this.#hashedID) {
142
+ this.#hashedID = createHash("sha224").update(this.#id).digest("hex").substring(0, 10);
143
+ }
144
+
145
+ return this.#hashedID;
146
+ }
147
+
148
+ /**
149
+ * Subscribe to watch events. This is an EventEmitter that emits the following events:
150
+ *
151
+ * Use {@link WatchEvent} for the event names.
152
+ *
153
+ * @returns an EventEmitter
154
+ */
155
+ public get events(): EventEmitter {
156
+ return this.#events;
117
157
  }
118
158
 
119
159
  /**
120
- * The main watch runner. This will run until the process is terminated or the watch is aborted.
160
+ * Build the URL and request options for the watch.
161
+ *
162
+ * @returns the URL and request options
121
163
  */
122
- async function runner() {
123
- let doneCalled = false;
164
+ #buildURL = async () => {
165
+ // Build the path and query params for the resource, excluding the name
166
+ const { opts, serverUrl } = await k8sCfg("GET");
167
+ const url = pathBuilder(serverUrl, this.#model, this.#filters, true);
168
+
169
+ // Set the watch ID if it does not exist (this does not change on reconnect)
170
+ if (!this.#id) {
171
+ this.#id = url.pathname + url.search;
172
+ }
124
173
 
125
- bindAbortController();
174
+ // Enable the watch query param
175
+ url.searchParams.set("watch", "true");
126
176
 
127
- // Create a stream to read the response body
128
- const stream = byline.createStream();
177
+ // If a name is specified, add it to the query params
178
+ if (this.#filters.name) {
179
+ url.searchParams.set("fieldSelector", `metadata.name=${this.#filters.name}`);
180
+ }
129
181
 
130
- const onError = (err: Error) => {
131
- stream.removeAllListeners();
182
+ // If a resource version is specified, add it to the query params
183
+ if (this.#watchCfg.resourceVersion) {
184
+ url.searchParams.set("resourceVersion", this.#watchCfg.resourceVersion);
185
+ }
132
186
 
133
- if (!doneCalled) {
134
- doneCalled = true;
187
+ // Enable watch bookmarks
188
+ url.searchParams.set("allowWatchBookmarks", "true");
135
189
 
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
- };
190
+ // Add the abort signal to the request options
191
+ opts.signal = this.#abortController.signal;
145
192
 
146
- // Cleanup the stream listeners
147
- const cleanup = () => {
148
- if (!doneCalled) {
149
- doneCalled = true;
150
- stream.removeAllListeners();
151
- }
152
- };
193
+ return { opts, url };
194
+ };
153
195
 
196
+ /** Run the watch. */
197
+ #runner = async () => {
154
198
  try {
199
+ // Build the URL and request options
200
+ const { opts, url } = await this.#buildURL();
201
+
202
+ // Create a stream to read the response body
203
+ this.#stream = byline.createStream();
204
+
205
+ // Bind the stream events
206
+ this.#stream.on("error", this.#errHandler);
207
+ this.#stream.on("close", this.#cleanup);
208
+ this.#stream.on("finish", this.#cleanup);
209
+
155
210
  // Make the actual request
156
211
  const response = await fetch(url, { ...opts });
157
212
 
213
+ // Reset the pending reconnect flag
214
+ this.#pendingReconnect = false;
215
+
216
+ // Reset the resync timer
217
+ void this.#scheduleResync();
218
+
158
219
  // If the request is successful, start listening for events
159
220
  if (response.ok) {
221
+ this.#events.emit(WatchEvent.CONNECT);
222
+
160
223
  const { body } = response;
161
224
 
162
225
  // Reset the retry count
163
- retryCount = 0;
164
-
165
- stream.on("error", onError);
166
- stream.on("close", cleanup);
167
- stream.on("finish", cleanup);
226
+ this.#retryCount = 0;
168
227
 
169
228
  // Listen for events and call the callback function
170
- stream.on("data", line => {
229
+ this.#stream.on("data", async line => {
171
230
  try {
172
231
  // Parse the event payload
173
232
  const { object: payload, type: phase } = JSON.parse(line) as {
@@ -175,53 +234,157 @@ export async function ExecWatch<T extends GenericClass>(
175
234
  object: InstanceType<T>;
176
235
  };
177
236
 
178
- // Call the callback function with the parsed payload
179
- void callback(payload, phase as WatchPhase);
237
+ void this.#scheduleResync();
238
+
239
+ // If the watch is too old, remove the resourceVersion and reload the watch
240
+ if (phase === WatchPhase.Error && payload.code === 410) {
241
+ throw {
242
+ name: "TooOld",
243
+ message: this.#watchCfg.resourceVersion!,
244
+ };
245
+ }
246
+
247
+ // If the event is a bookmark, emit the event and skip the callback
248
+ if (phase === WatchPhase.Bookmark) {
249
+ this.#events.emit(WatchEvent.BOOKMARK, payload);
250
+ } else {
251
+ this.#events.emit(WatchEvent.DATA, payload, phase);
252
+
253
+ // Call the callback function with the parsed payload
254
+ await this.#callback(payload, phase as WatchPhase);
255
+ }
256
+
257
+ // Update the resource version if the callback was successful
258
+ this.#setResourceVersion(payload.metadata.resourceVersion);
180
259
  } catch (err) {
181
- watchCfg.logFn?.(err, "watch callback error");
260
+ if (err.name === "TooOld") {
261
+ // Prevent any body events from firing
262
+ body.removeAllListeners();
263
+
264
+ // Reload the watch
265
+ void this.#errHandler(err);
266
+ return;
267
+ }
268
+
269
+ this.#events.emit(WatchEvent.DATA_ERROR, err);
182
270
  }
183
271
  });
184
272
 
185
- body.on("error", onError);
186
- body.on("close", cleanup);
187
- body.on("finish", cleanup);
273
+ // Bind the body events
274
+ body.on("error", this.#errHandler);
275
+ body.on("close", this.#cleanup);
276
+ body.on("finish", this.#cleanup);
188
277
 
189
278
  // Pipe the response body to the stream
190
- body.pipe(stream);
279
+ body.pipe(this.#stream);
191
280
  } else {
192
- throw new Error(`watch failed: ${response.status} ${response.statusText}`);
281
+ throw new Error(`watch connect failed: ${response.status} ${response.statusText}`);
193
282
  }
194
283
  } catch (e) {
195
- onError(e);
284
+ void this.#errHandler(e);
196
285
  }
286
+ };
197
287
 
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++;
288
+ /**
289
+ * Resync the watch.
290
+ *
291
+ * @returns the error handler
292
+ */
293
+ #resync = () =>
294
+ this.#errHandler({
295
+ name: "Resync",
296
+ message: "Resync triggered by resyncIntervalSec",
297
+ });
298
+
299
+ /** Clear the resync timer and schedule a new one. */
300
+ #scheduleResync = async () => {
301
+ clearTimeout(this.#resyncTimer);
302
+ this.#resyncTimer = setTimeout(this.#resync, this.#watchCfg.resyncIntervalSec! * 1000);
303
+ };
207
304
 
208
- watchCfg.logFn?.(`retrying watch ${retryCount}/${watchCfg.retryMax}`);
305
+ /**
306
+ * Update the resource version.
307
+ *
308
+ * @param resourceVersion - the new resource version
309
+ */
310
+ #setResourceVersion = (resourceVersion?: string) => {
311
+ this.#watchCfg.resourceVersion = resourceVersion;
312
+ this.#events.emit(WatchEvent.RESOURCE_VERSION, resourceVersion);
313
+ };
209
314
 
210
- // Sleep for the specified delay or 5 seconds
211
- await new Promise(r => setTimeout(r, watchCfg.retryDelaySec! * 1000));
315
+ /**
316
+ * Reload the watch after an error.
317
+ *
318
+ * @param err - the error that occurred
319
+ */
320
+ #reconnect = async (err: Error) => {
321
+ // If there are more attempts, retry the watch (undefined is unlimited retries)
322
+ if (this.#watchCfg.retryMax === undefined || this.#watchCfg.retryMax > this.#retryCount) {
323
+ // Sleep for the specified delay, but check every 500ms if the watch has been aborted
324
+ let delay = this.#watchCfg.retryDelaySec! * 1000;
325
+ while (delay > 0) {
326
+ if (this.#abortController.signal.aborted) {
327
+ return;
328
+ }
329
+ delay -= 500;
330
+ await new Promise(resolve => setTimeout(resolve, 500));
331
+ }
212
332
 
213
- // Retry the watch after the delay
214
- await runner();
333
+ this.#retryCount++;
334
+ this.#events.emit(WatchEvent.RECONNECT, err, this.#retryCount);
335
+
336
+ if (this.#pendingReconnect) {
337
+ this.#events.emit(WatchEvent.RECONNECT_PENDING);
215
338
  } else {
216
- // Otherwise, call the finally function if it exists
217
- if (watchCfg.retryFail) {
218
- watchCfg.retryFail(e);
219
- }
339
+ this.#pendingReconnect = true;
340
+ this.#cleanup();
341
+
342
+ // Retry the watch after the delay
343
+ await this.#runner();
220
344
  }
345
+ } else {
346
+ // Otherwise, call the finally function if it exists
347
+ this.#events.emit(WatchEvent.GIVE_UP, err);
348
+ this.close();
221
349
  }
222
- }
350
+ };
223
351
 
224
- await runner();
352
+ /**
353
+ * Handle errors from the stream.
354
+ *
355
+ * @param err - the error that occurred
356
+ */
357
+ #errHandler = async (err: Error) => {
358
+ switch (err.name) {
359
+ case "AbortError":
360
+ clearTimeout(this.#resyncTimer);
361
+ this.#cleanup();
362
+ this.#events.emit(WatchEvent.ABORT, err);
363
+ return;
364
+
365
+ case "TooOld":
366
+ // Purge the resource version if it is too old
367
+ this.#setResourceVersion(undefined);
368
+ this.#events.emit(WatchEvent.OLD_RESOURCE_VERSION, err.message);
369
+ break;
370
+
371
+ case "Resync":
372
+ this.#events.emit(WatchEvent.RESYNC, err);
373
+ break;
374
+
375
+ default:
376
+ this.#events.emit(WatchEvent.NETWORK_ERROR, err);
377
+ break;
378
+ }
225
379
 
226
- return abortWrapper;
380
+ await this.#reconnect(err);
381
+ };
382
+
383
+ /** Cleanup the stream and listeners. */
384
+ #cleanup = () => {
385
+ if (this.#stream) {
386
+ this.#stream.removeAllListeners();
387
+ this.#stream.destroy();
388
+ }
389
+ };
227
390
  }
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