pepr 0.35.0 → 0.36.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.
package/dist/sdk/sdk.d.ts CHANGED
@@ -24,10 +24,12 @@ export declare function containers(request: PeprValidateRequest<a.Pod> | PeprMut
24
24
  export declare function writeEvent(cr: GenericKind, event: Partial<kind.CoreEvent>, eventType: string, eventReason: string, reportingComponent: string, reportingInstance: string): Promise<void>;
25
25
  /**
26
26
  * Get the owner reference for a custom resource
27
- * @param cr the custom resource to get the owner reference for
28
- * @returns the owner reference for the custom resource
27
+ * @param customResource the custom resource to get the owner reference for
28
+ * @param blockOwnerDeletion if true, AND if the owner has the "foregroundDeletion" finalizer, then the owner cannot be deleted from the key-value store until this reference is removed.
29
+ * @param controller if true, this reference points to the managing controller.
30
+ * @returns the owner reference array for the custom resource
29
31
  */
30
- export declare function getOwnerRefFrom(cr: GenericKind): V1OwnerReference[];
32
+ export declare function getOwnerRefFrom(customResource: GenericKind, blockOwnerDeletion?: boolean, controller?: boolean): V1OwnerReference[];
31
33
  /**
32
34
  * Sanitize a resource name to make it a valid Kubernetes resource name.
33
35
  *
@@ -1 +1 @@
1
- {"version":3,"file":"sdk.d.ts","sourceRoot":"","sources":["../../src/sdk/sdk.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,mBAAmB,EAAE,MAAM,yBAAyB,CAAC;AAC9D,OAAO,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAC1D,OAAO,EAAE,CAAC,EAAE,MAAM,QAAQ,CAAC;AAC3B,OAAO,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAC3D,OAAO,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAC;AACvD,OAAO,EAAO,IAAI,EAAE,MAAM,0BAA0B,CAAC;AAGrD;;;;;GAKG;AACH,wBAAgB,UAAU,CACxB,OAAO,EAAE,mBAAmB,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,iBAAiB,CAAC,CAAC,CAAC,GAAG,CAAC,EAC9D,aAAa,CAAC,EAAE,YAAY,GAAG,gBAAgB,GAAG,qBAAqB,mDAgBxE;AAED;;;;;;;;;GASG;AACH,wBAAsB,UAAU,CAC9B,EAAE,EAAE,WAAW,EACf,KAAK,EAAE,OAAO,CAAC,IAAI,CAAC,SAAS,CAAC,EAC9B,SAAS,EAAE,MAAM,EACjB,WAAW,EAAE,MAAM,EACnB,kBAAkB,EAAE,MAAM,EAC1B,iBAAiB,EAAE,MAAM,iBAwB1B;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,EAAE,EAAE,WAAW,GAAG,gBAAgB,EAAE,CAWnE;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,UAYhD"}
1
+ {"version":3,"file":"sdk.d.ts","sourceRoot":"","sources":["../../src/sdk/sdk.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,mBAAmB,EAAE,MAAM,yBAAyB,CAAC;AAC9D,OAAO,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAC1D,OAAO,EAAE,CAAC,EAAE,MAAM,QAAQ,CAAC;AAC3B,OAAO,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAC3D,OAAO,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAC;AACvD,OAAO,EAAO,IAAI,EAAE,MAAM,0BAA0B,CAAC;AAGrD;;;;;GAKG;AACH,wBAAgB,UAAU,CACxB,OAAO,EAAE,mBAAmB,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,iBAAiB,CAAC,CAAC,CAAC,GAAG,CAAC,EAC9D,aAAa,CAAC,EAAE,YAAY,GAAG,gBAAgB,GAAG,qBAAqB,mDAgBxE;AAED;;;;;;;;;GASG;AACH,wBAAsB,UAAU,CAC9B,EAAE,EAAE,WAAW,EACf,KAAK,EAAE,OAAO,CAAC,IAAI,CAAC,SAAS,CAAC,EAC9B,SAAS,EAAE,MAAM,EACjB,WAAW,EAAE,MAAM,EACnB,kBAAkB,EAAE,MAAM,EAC1B,iBAAiB,EAAE,MAAM,iBAwB1B;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAC7B,cAAc,EAAE,WAAW,EAC3B,kBAAkB,CAAC,EAAE,OAAO,EAC5B,UAAU,CAAC,EAAE,OAAO,GACnB,gBAAgB,EAAE,CAcpB;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,UAYhD"}
package/package.json CHANGED
@@ -13,7 +13,7 @@
13
13
  "/dist",
14
14
  "/src"
15
15
  ],
16
- "version": "0.35.0",
16
+ "version": "0.36.0",
17
17
  "main": "dist/lib.js",
18
18
  "types": "dist/lib.d.ts",
19
19
  "scripts": {
@@ -39,18 +39,18 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "@types/ramda": "0.30.2",
42
- "express": "4.19.2",
42
+ "express": "4.21.0",
43
43
  "fast-json-patch": "3.1.1",
44
44
  "json-pointer": "^0.6.2",
45
- "kubernetes-fluent-client": "3.0.2",
46
- "pino": "9.3.2",
45
+ "kubernetes-fluent-client": "3.0.3",
46
+ "pino": "9.4.0",
47
47
  "pino-pretty": "11.2.2",
48
48
  "prom-client": "15.1.3",
49
49
  "ramda": "0.30.1"
50
50
  },
51
51
  "devDependencies": {
52
- "@commitlint/cli": "19.4.1",
53
- "@commitlint/config-conventional": "19.4.1",
52
+ "@commitlint/cli": "19.5.0",
53
+ "@commitlint/config-conventional": "19.5.0",
54
54
  "@fast-check/jest": "^2.0.1",
55
55
  "@jest/globals": "29.7.0",
56
56
  "@types/eslint": "9.6.1",
@@ -198,12 +198,13 @@ export class Capability implements CapabilityExport {
198
198
  namespaces: [],
199
199
  labels: {},
200
200
  annotations: {},
201
+ deletionTimestamp: false,
201
202
  },
202
203
  };
203
204
 
204
205
  const bindings = this.#bindings;
205
206
  const prefix = `${this.#name}: ${model.name}`;
206
- const commonChain = { WithLabel, WithAnnotation, Mutate, Validate, Watch, Reconcile };
207
+ const commonChain = { WithLabel, WithAnnotation, WithDeletionTimestamp, Mutate, Validate, Watch, Reconcile };
207
208
  const isNotEmpty = (value: object) => Object.keys(value).length > 0;
208
209
  const log = (message: string, cbString: string) => {
209
210
  const filteredObj = pickBy(isNotEmpty, binding.filters);
@@ -277,6 +278,12 @@ export class Capability implements CapabilityExport {
277
278
  return { ...commonChain, WithName };
278
279
  }
279
280
 
281
+ function WithDeletionTimestamp(): BindingFilter<T> {
282
+ Log.debug("Add deletionTimestamp filter");
283
+ binding.filters.deletionTimestamp = true;
284
+ return commonChain;
285
+ }
286
+
280
287
  function WithName(name: string): BindingFilter<T> {
281
288
  Log.debug(`Add name filter ${name}`, prefix);
282
289
  binding.filters.name = name;
@@ -301,6 +308,7 @@ export class Capability implements CapabilityExport {
301
308
  ...commonChain,
302
309
  InNamespace,
303
310
  WithName,
311
+ WithDeletionTimestamp,
304
312
  };
305
313
  }
306
314
 
@@ -29,6 +29,7 @@ describe("Fuzzing shouldSkipRequest", () => {
29
29
  namespaces: fc.array(fc.string()),
30
30
  labels: fc.dictionary(fc.string(), fc.string()),
31
31
  annotations: fc.dictionary(fc.string(), fc.string()),
32
+ deletionTimestamp: fc.boolean(),
32
33
  }),
33
34
  }),
34
35
  fc.record({
@@ -41,6 +42,11 @@ describe("Fuzzing shouldSkipRequest", () => {
41
42
  version: fc.string(),
42
43
  kind: fc.string(),
43
44
  }),
45
+ object: fc.record({
46
+ metadata: fc.record({
47
+ deletionTimestamp: fc.option(fc.date()),
48
+ }),
49
+ }),
44
50
  }),
45
51
  fc.array(fc.string()),
46
52
  (binding, req, capabilityNamespaces) => {
@@ -69,6 +75,7 @@ describe("Property-Based Testing shouldSkipRequest", () => {
69
75
  namespaces: fc.array(fc.string()),
70
76
  labels: fc.dictionary(fc.string(), fc.string()),
71
77
  annotations: fc.dictionary(fc.string(), fc.string()),
78
+ deletionTimestamp: fc.boolean(),
72
79
  }),
73
80
  }),
74
81
  fc.record({
@@ -81,6 +88,11 @@ describe("Property-Based Testing shouldSkipRequest", () => {
81
88
  version: fc.string(),
82
89
  kind: fc.string(),
83
90
  }),
91
+ object: fc.record({
92
+ metadata: fc.record({
93
+ deletionTimestamp: fc.option(fc.date()),
94
+ }),
95
+ }),
84
96
  }),
85
97
  fc.array(fc.string()),
86
98
  (binding, req, capabilityNamespaces) => {
@@ -103,6 +115,7 @@ test("should reject when name does not match", () => {
103
115
  namespaces: [],
104
116
  labels: {},
105
117
  annotations: {},
118
+ deletionTimestamp: false,
106
119
  },
107
120
  callback,
108
121
  };
@@ -121,6 +134,7 @@ test("should reject when kind does not match", () => {
121
134
  namespaces: [],
122
135
  labels: {},
123
136
  annotations: {},
137
+ deletionTimestamp: false,
124
138
  },
125
139
  callback,
126
140
  };
@@ -139,6 +153,7 @@ test("should reject when group does not match", () => {
139
153
  namespaces: [],
140
154
  labels: {},
141
155
  annotations: {},
156
+ deletionTimestamp: false,
142
157
  },
143
158
  callback,
144
159
  };
@@ -161,6 +176,7 @@ test("should reject when version does not match", () => {
161
176
  namespaces: [],
162
177
  labels: {},
163
178
  annotations: {},
179
+ deletionTimestamp: false,
164
180
  },
165
181
  callback,
166
182
  };
@@ -179,6 +195,7 @@ test("should allow when group, version, and kind match", () => {
179
195
  namespaces: [],
180
196
  labels: {},
181
197
  annotations: {},
198
+ deletionTimestamp: false,
182
199
  },
183
200
  callback,
184
201
  };
@@ -201,6 +218,7 @@ test("should allow when kind match and others are empty", () => {
201
218
  namespaces: [],
202
219
  labels: {},
203
220
  annotations: {},
221
+ deletionTimestamp: false,
204
222
  },
205
223
  callback,
206
224
  };
@@ -219,6 +237,7 @@ test("should reject when teh capability namespace does not match", () => {
219
237
  namespaces: [],
220
238
  labels: {},
221
239
  annotations: {},
240
+ deletionTimestamp: false,
222
241
  },
223
242
  callback,
224
243
  };
@@ -237,6 +256,7 @@ test("should reject when namespace does not match", () => {
237
256
  namespaces: ["bleh"],
238
257
  labels: {},
239
258
  annotations: {},
259
+ deletionTimestamp: false,
240
260
  },
241
261
  callback,
242
262
  };
@@ -255,6 +275,7 @@ test("should allow when namespace is match", () => {
255
275
  namespaces: ["default", "unicorn", "things"],
256
276
  labels: {},
257
277
  annotations: {},
278
+ deletionTimestamp: false,
258
279
  },
259
280
  callback,
260
281
  };
@@ -275,6 +296,7 @@ test("should reject when label does not match", () => {
275
296
  foo: "bar",
276
297
  },
277
298
  annotations: {},
299
+ deletionTimestamp: false,
278
300
  },
279
301
  callback,
280
302
  };
@@ -290,7 +312,7 @@ test("should allow when label is match", () => {
290
312
  kind: podKind,
291
313
  filters: {
292
314
  name: "",
293
-
315
+ deletionTimestamp: false,
294
316
  namespaces: [],
295
317
  labels: {
296
318
  foo: "bar",
@@ -324,6 +346,7 @@ test("should reject when annotation does not match", () => {
324
346
  annotations: {
325
347
  foo: "bar",
326
348
  },
349
+ deletionTimestamp: false,
327
350
  },
328
351
  callback,
329
352
  };
@@ -345,6 +368,7 @@ test("should allow when annotation is match", () => {
345
368
  foo: "bar",
346
369
  test: "test1",
347
370
  },
371
+ deletionTimestamp: false,
348
372
  },
349
373
  callback,
350
374
  };
@@ -368,6 +392,7 @@ test("should use `oldObject` when the operation is `DELETE`", () => {
368
392
  filters: {
369
393
  name: "",
370
394
  namespaces: [],
395
+ deletionTimestamp: false,
371
396
  labels: {
372
397
  "app.kubernetes.io/name": "cool-name-podinfo",
373
398
  },
@@ -382,3 +407,62 @@ test("should use `oldObject` when the operation is `DELETE`", () => {
382
407
 
383
408
  expect(shouldSkipRequest(binding, pod, [])).toBe(false);
384
409
  });
410
+
411
+ test("should skip processing when deletionTimestamp is not present on pod", () => {
412
+ const binding = {
413
+ model: kind.Pod,
414
+ event: Event.Any,
415
+ kind: podKind,
416
+ filters: {
417
+ name: "",
418
+ namespaces: [],
419
+ labels: {},
420
+ annotations: {
421
+ foo: "bar",
422
+ test: "test1",
423
+ },
424
+ deletionTimestamp: true,
425
+ },
426
+ callback,
427
+ };
428
+
429
+ const pod = CreatePod();
430
+ pod.object.metadata = pod.object.metadata || {};
431
+ pod.object.metadata.annotations = {
432
+ foo: "bar",
433
+ test: "test1",
434
+ test2: "test2",
435
+ };
436
+
437
+ expect(shouldSkipRequest(binding, pod, [])).toBe(true);
438
+ });
439
+
440
+ test("should processing when deletionTimestamp is not present on pod", () => {
441
+ const binding = {
442
+ model: kind.Pod,
443
+ event: Event.Any,
444
+ kind: podKind,
445
+ filters: {
446
+ name: "",
447
+ namespaces: [],
448
+ labels: {},
449
+ annotations: {
450
+ foo: "bar",
451
+ test: "test1",
452
+ },
453
+ deletionTimestamp: true,
454
+ },
455
+ callback,
456
+ };
457
+
458
+ const pod = CreatePod();
459
+ pod.object.metadata = pod.object.metadata || {};
460
+ pod.object.metadata!.deletionTimestamp = new Date("2021-09-01T00:00:00Z");
461
+ pod.object.metadata.annotations = {
462
+ foo: "bar",
463
+ test: "test1",
464
+ test2: "test2",
465
+ };
466
+
467
+ expect(shouldSkipRequest(binding, pod, [])).toBe(false);
468
+ });
package/src/lib/filter.ts CHANGED
@@ -22,6 +22,15 @@ export function shouldSkipRequest(binding: Binding, req: AdmissionRequest, capab
22
22
  const { metadata } = srcObject || {};
23
23
  const combinedNamespaces = [...namespaces, ...capabilityNamespaces];
24
24
 
25
+ // Delete bindings do not work through admission with DeletionTimestamp
26
+ if (binding.event.includes(Event.Delete) && binding.filters?.deletionTimestamp) {
27
+ return true;
28
+ }
29
+
30
+ // Test for deletionTimestamp
31
+ if (binding.filters?.deletionTimestamp && !req.object.metadata?.deletionTimestamp) {
32
+ return true;
33
+ }
25
34
  // Test for matching operation
26
35
  if (!binding.event.includes(operation) && !binding.event.includes(Event.Any)) {
27
36
  return true;
@@ -1056,6 +1056,40 @@ describe("filterMatcher", () => {
1056
1056
  expect(result).toEqual("Ignoring Watch Callback: Cannot use a namespace filter in a namespace object.");
1057
1057
  });
1058
1058
 
1059
+ test("return deletionTimestamp error when there is no deletionTimestamp in the object", () => {
1060
+ const binding = {
1061
+ filters: { deletionTimestamp: true },
1062
+ };
1063
+ const obj = {
1064
+ metadata: {},
1065
+ };
1066
+ const capabilityNamespaces: string[] = [];
1067
+ const result = filterNoMatchReason(
1068
+ binding as unknown as Partial<Binding>,
1069
+ obj as unknown as Partial<KubernetesObject>,
1070
+ capabilityNamespaces,
1071
+ );
1072
+ expect(result).toEqual("Ignoring Watch Callback: Object does not have a deletion timestamp.");
1073
+ });
1074
+
1075
+ test("return no deletionTimestamp error when there is a deletionTimestamp in the object", () => {
1076
+ const binding = {
1077
+ filters: { deletionTimestamp: true },
1078
+ };
1079
+ const obj = {
1080
+ metadata: {
1081
+ deletionTimestamp: "2021-01-01T00:00:00Z",
1082
+ },
1083
+ };
1084
+ const capabilityNamespaces: string[] = [];
1085
+ const result = filterNoMatchReason(
1086
+ binding as unknown as Partial<Binding>,
1087
+ obj as unknown as Partial<KubernetesObject>,
1088
+ capabilityNamespaces,
1089
+ );
1090
+ expect(result).not.toEqual("Ignoring Watch Callback: Object does not have a deletion timestamp.");
1091
+ });
1092
+
1059
1093
  test("returns label overlap error when there is no overlap between binding and object labels", () => {
1060
1094
  const binding = {
1061
1095
  filters: { labels: { key: "value" } },
@@ -73,6 +73,11 @@ export function filterNoMatchReason(
73
73
  obj: Partial<KubernetesObject>,
74
74
  capabilityNamespaces: string[],
75
75
  ): string {
76
+ // binding deletionTimestamp filter and object deletionTimestamp dont match
77
+ if (binding.filters?.deletionTimestamp && !obj.metadata?.deletionTimestamp) {
78
+ return `Ignoring Watch Callback: Object does not have a deletion timestamp.`;
79
+ }
80
+
76
81
  // binding kind is namespace with a InNamespace filter
77
82
  if (binding.kind && binding.kind.kind === "Namespace" && binding.filters && binding.filters.namespaces.length !== 0) {
78
83
  return `Ignoring Watch Callback: Cannot use a namespace filter in a namespace object.`;
@@ -1,58 +1,152 @@
1
- import { beforeEach, describe, expect, jest, test } from "@jest/globals";
2
- import { KubernetesObject } from "@kubernetes/client-node";
1
+ import { afterEach, describe, expect, jest, it } from "@jest/globals";
3
2
  import { WatchPhase } from "kubernetes-fluent-client/dist/fluent/types";
4
3
  import { Queue } from "./queue";
5
4
 
5
+ import Log from "./logger";
6
+ jest.mock("./logger");
7
+
6
8
  describe("Queue", () => {
7
- let queue: Queue<KubernetesObject>;
9
+ afterEach(() => {
10
+ jest.resetAllMocks();
11
+ });
12
+
13
+ it("is uniquely identifiable", () => {
14
+ const name = "kind/namespace";
15
+ const queue = new Queue(name);
16
+
17
+ const label = queue.label();
18
+
19
+ expect(label).toEqual(
20
+ expect.objectContaining({
21
+ // given name of queue
22
+ name,
23
+
24
+ // unique, generated value (to disambiguate similarly-named queues)
25
+ // <epoch timestamp (ms)>-<4 char hex>
26
+ uid: expect.stringMatching(/[0-9]{13}-[0-9a-f]{4}/),
27
+ }),
28
+ );
29
+ });
30
+
31
+ it("exposes runtime stats", async () => {
32
+ const name = "kind/namespace";
33
+ const queue = new Queue(name);
34
+
35
+ expect(queue.stats()).toEqual(
36
+ expect.objectContaining({
37
+ queue: queue.label(),
38
+ stats: { length: 0 },
39
+ }),
40
+ );
41
+
42
+ const kubeObj = { metadata: { name: "test-nm", namespace: "test-ns" } };
43
+ const watchCb = () =>
44
+ new Promise<void>(res => {
45
+ setTimeout(res, 100);
46
+ });
47
+
48
+ await Promise.all([
49
+ queue.enqueue(kubeObj, WatchPhase.Added, watchCb),
50
+ queue.enqueue(kubeObj, WatchPhase.Added, watchCb),
51
+ queue.enqueue(kubeObj, WatchPhase.Added, watchCb),
52
+ queue.enqueue(kubeObj, WatchPhase.Added, watchCb),
53
+ ]);
54
+
55
+ const logDebug = Log.debug as jest.Mock;
56
+ const stats = logDebug.mock.calls
57
+ .flat()
58
+ .map(m => JSON.stringify(m))
59
+ .filter(m => m.includes('"stats":'));
8
60
 
9
- beforeEach(() => {
10
- queue = new Queue();
61
+ [
62
+ '"length":1', // 1st entry runs near-immediately, so queue won't fill
63
+ '"length":1', // afterward, queue fills & unfills as callbacks process
64
+ '"length":2',
65
+ '"length":3',
66
+ '"length":3',
67
+ '"length":2',
68
+ '"length":1',
69
+ '"length":0',
70
+ ].map((exp, idx) => {
71
+ expect(stats[idx]).toEqual(expect.stringContaining(exp));
72
+ });
11
73
  });
12
74
 
13
- test("enqueue should add a pod to the queue and return a promise", async () => {
14
- const pod = {
15
- metadata: { name: "test-pod", namespace: "test-pod" },
16
- };
17
- const promise = queue.enqueue(pod, WatchPhase.Added);
75
+ it("resolves when an enqueued event dequeues without error", async () => {
76
+ const name = "kind/namespace";
77
+ const queue = new Queue(name);
78
+
79
+ const kubeObj = { metadata: { name: "test-nm", namespace: "test-ns" } };
80
+ const watchCb = () =>
81
+ new Promise<void>(res => {
82
+ setTimeout(res, 10);
83
+ });
84
+
85
+ const promise = queue.enqueue(kubeObj, WatchPhase.Added, watchCb);
18
86
  expect(promise).toBeInstanceOf(Promise);
19
- await promise;
87
+
88
+ await expect(promise).resolves.not.toThrow();
20
89
  });
21
90
 
22
- test("dequeue should process pods in FIFO order", async () => {
23
- const mockPod = {
24
- metadata: { name: "test-pod", namespace: "test-namespace" },
25
- };
26
- const mockPod2 = {
27
- metadata: { name: "test-pod-2", namespace: "test-namespace-2" },
28
- };
29
-
30
- // Enqueue two packages
31
- const promise1 = queue.enqueue(mockPod, WatchPhase.Added);
32
- const promise2 = queue.enqueue(mockPod2, WatchPhase.Modified);
33
-
34
- // Wait for both promises to resolve
35
- await promise1;
36
- await promise2;
91
+ it("rejects when an enqueued event dequeues with error", async () => {
92
+ const name = "kind/namespace";
93
+ const queue = new Queue(name);
94
+
95
+ const kubeObj = { metadata: { name: "test-nm", namespace: "test-ns" } };
96
+ const watchCb = () =>
97
+ new Promise<void>((_, reject) => {
98
+ setTimeout(() => {
99
+ reject("oof");
100
+ }, 10);
101
+ });
102
+
103
+ const promise = queue.enqueue(kubeObj, WatchPhase.Added, watchCb);
104
+ expect(promise).toBeInstanceOf(Promise);
105
+
106
+ await expect(promise).rejects.toBe("oof");
37
107
  });
38
108
 
39
- test("dequeue should handle errors in pod processing", async () => {
40
- const mockPod = {
41
- metadata: { name: "test-pod", namespace: "test-namespace" },
42
- };
43
- const error = new Error("reconciliation failed");
44
- jest.spyOn(queue, "setReconcile").mockRejectedValueOnce(error as never);
45
-
46
- try {
47
- await queue.enqueue(mockPod, WatchPhase.Added);
48
- } catch (e) {
49
- expect(e).toBe(error);
50
- }
51
-
52
- // Ensure that the queue is ready to process the next pod
53
- const mockPod2 = {
54
- metadata: { name: "test-pod-2", namespace: "test-namespace-2" },
55
- };
56
- await queue.enqueue(mockPod2, WatchPhase.Modified);
109
+ it("processes events in FIFO order", async () => {
110
+ const name = "kind/namespace";
111
+ const queue = new Queue(name);
112
+
113
+ const kubeObj = { metadata: { name: "test-nm", namespace: "test-ns" } };
114
+ const watchA = () =>
115
+ new Promise<void>(resolve => {
116
+ setTimeout(() => {
117
+ Log.info("watchA");
118
+ resolve();
119
+ }, 15);
120
+ });
121
+ const watchB = () =>
122
+ new Promise<void>(resolve => {
123
+ setTimeout(() => {
124
+ Log.info("watchB");
125
+ resolve();
126
+ }, 10);
127
+ });
128
+ const watchC = () =>
129
+ new Promise<void>(resolve => {
130
+ setTimeout(() => {
131
+ Log.info("watchC");
132
+ resolve();
133
+ }, 5);
134
+ });
135
+
136
+ await Promise.all([
137
+ queue.enqueue(kubeObj, WatchPhase.Added, watchA),
138
+ queue.enqueue(kubeObj, WatchPhase.Added, watchB),
139
+ queue.enqueue(kubeObj, WatchPhase.Added, watchC),
140
+ ]);
141
+
142
+ const logInfo = Log.info as jest.Mock;
143
+ const calls = logInfo.mock.calls
144
+ .flat()
145
+ .map(m => JSON.stringify(m))
146
+ .filter(m => /"watch[ABC]"/.test(m));
147
+
148
+ ['"watchA"', '"watchB"', '"watchC"'].map((exp, idx) => {
149
+ expect(calls[idx]).toEqual(expect.stringContaining(exp));
150
+ });
57
151
  });
58
152
  });
package/src/lib/queue.ts CHANGED
@@ -2,11 +2,15 @@
2
2
  // SPDX-FileCopyrightText: 2023-Present The Pepr Authors
3
3
  import { KubernetesObject } from "@kubernetes/client-node";
4
4
  import { WatchPhase } from "kubernetes-fluent-client/dist/fluent/types";
5
+ import { randomBytes } from "node:crypto";
5
6
  import Log from "./logger";
6
7
 
8
+ type WatchCallback = (obj: KubernetesObject, phase: WatchPhase) => Promise<void>;
9
+
7
10
  type QueueItem<K extends KubernetesObject> = {
8
11
  item: K;
9
- type: WatchPhase;
12
+ phase: WatchPhase;
13
+ callback: WatchCallback;
10
14
  resolve: (value: void | PromiseLike<void>) => void;
11
15
  reject: (reason?: string) => void;
12
16
  };
@@ -15,16 +19,27 @@ type QueueItem<K extends KubernetesObject> = {
15
19
  * Queue is a FIFO queue for reconciling
16
20
  */
17
21
  export class Queue<K extends KubernetesObject> {
22
+ #name: string;
23
+ #uid: string;
18
24
  #queue: QueueItem<K>[] = [];
19
25
  #pendingPromise = false;
20
- #reconcile?: (obj: KubernetesObject, type: WatchPhase) => Promise<void>;
21
26
 
22
- constructor() {
23
- this.#reconcile = async () => await new Promise(resolve => resolve());
27
+ constructor(name: string) {
28
+ this.#name = name;
29
+ this.#uid = `${Date.now()}-${randomBytes(2).toString("hex")}`;
30
+ }
31
+
32
+ label() {
33
+ return { name: this.#name, uid: this.#uid };
24
34
  }
25
35
 
26
- setReconcile(reconcile: (obj: KubernetesObject, type: WatchPhase) => Promise<void>) {
27
- this.#reconcile = reconcile;
36
+ stats() {
37
+ return {
38
+ queue: this.label(),
39
+ stats: {
40
+ length: this.#queue.length,
41
+ },
42
+ };
28
43
  }
29
44
 
30
45
  /**
@@ -32,12 +47,23 @@ export class Queue<K extends KubernetesObject> {
32
47
  * reconciled.
33
48
  *
34
49
  * @param item The object to reconcile
50
+ * @param type The watch phase requested for reconcile
51
+ * @param reconcile The callback to enqueue for reconcile
35
52
  * @returns A promise that resolves when the object is reconciled
36
53
  */
37
- enqueue(item: K, type: WatchPhase) {
38
- Log.debug(`Enqueueing ${item.metadata!.namespace}/${item.metadata!.name}`);
54
+ enqueue(item: K, phase: WatchPhase, reconcile: WatchCallback) {
55
+ const note = {
56
+ queue: this.label(),
57
+ item: {
58
+ name: item.metadata?.name,
59
+ namespace: item.metadata?.namespace,
60
+ resourceVersion: item.metadata?.resourceVersion,
61
+ },
62
+ };
63
+ Log.debug(note, "Enqueueing");
39
64
  return new Promise<void>((resolve, reject) => {
40
- this.#queue.push({ item, type, resolve, reject });
65
+ this.#queue.push({ item, phase, callback: reconcile, resolve, reject });
66
+ Log.debug(this.stats(), "Queue stats - push");
41
67
  return this.#dequeue();
42
68
  });
43
69
  }
@@ -68,16 +94,25 @@ export class Queue<K extends KubernetesObject> {
68
94
  this.#pendingPromise = true;
69
95
 
70
96
  // Reconcile the element
71
- if (this.#reconcile) {
72
- Log.debug(`Reconciling ${element.item.metadata!.name}`);
73
- await this.#reconcile(element.item, element.type);
74
- }
97
+ const note = {
98
+ queue: this.label(),
99
+ item: {
100
+ name: element.item.metadata?.name,
101
+ namespace: element.item.metadata?.namespace,
102
+ resourceVersion: element.item.metadata?.resourceVersion,
103
+ },
104
+ };
105
+ Log.debug(note, "Reconciling");
106
+ await element.callback(element.item, element.phase);
107
+ Log.debug(note, "Reconciled");
75
108
 
76
109
  element.resolve();
77
110
  } catch (e) {
78
111
  Log.debug(`Error reconciling ${element.item.metadata!.name}`, { error: e });
79
112
  element.reject(e);
80
113
  } finally {
114
+ Log.debug(this.stats(), "Queue stats - shift");
115
+
81
116
  // Reset the pending promise flag
82
117
  Log.debug("Resetting pending promise and dequeuing");
83
118
  this.#pendingPromise = false;