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