pepr 0.24.0 → 0.25.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.
@@ -4,7 +4,7 @@
4
4
  import { V1EnvVar } from "@kubernetes/client-node";
5
5
  import { kind } from "kubernetes-fluent-client";
6
6
  import { gzipSync } from "zlib";
7
-
7
+ import { secretOverLimit } from "../helpers";
8
8
  import { Assets } from ".";
9
9
  import { ModuleConfig } from "../module";
10
10
  import { Binding } from "../types";
@@ -45,9 +45,13 @@ export function watcher(assets: Assets, hash: string) {
45
45
  metadata: {
46
46
  name: app,
47
47
  namespace: "pepr-system",
48
+ annotations: {
49
+ "pepr.dev/description": config.description || "",
50
+ },
48
51
  labels: {
49
52
  app,
50
53
  "pepr.dev/controller": "watcher",
54
+ "pepr.dev/uuid": config.uuid,
51
55
  },
52
56
  },
53
57
  spec: {
@@ -168,9 +172,13 @@ export function deployment(assets: Assets, hash: string): kind.Deployment {
168
172
  metadata: {
169
173
  name,
170
174
  namespace: "pepr-system",
175
+ annotations: {
176
+ "pepr.dev/description": config.description || "",
177
+ },
171
178
  labels: {
172
179
  app,
173
180
  "pepr.dev/controller": "admission",
181
+ "pepr.dev/uuid": config.uuid,
174
182
  },
175
183
  },
176
184
  spec: {
@@ -294,18 +302,25 @@ export function moduleSecret(name: string, data: Buffer, hash: string): kind.Sec
294
302
  // Compress the data
295
303
  const compressed = gzipSync(data);
296
304
  const path = `module-${hash}.js.gz`;
297
- return {
298
- apiVersion: "v1",
299
- kind: "Secret",
300
- metadata: {
301
- name: `${name}-module`,
302
- namespace: "pepr-system",
303
- },
304
- type: "Opaque",
305
- data: {
306
- [path]: compressed.toString("base64"),
307
- },
308
- };
305
+ const compressedData = compressed.toString("base64");
306
+ if (secretOverLimit(compressedData)) {
307
+ const error = new Error(`Module secret for ${name} is over the 1MB limit`);
308
+ console.error("Uncaught Exception:", error);
309
+ process.exit(1);
310
+ } else {
311
+ return {
312
+ apiVersion: "v1",
313
+ kind: "Secret",
314
+ metadata: {
315
+ name: `${name}-module`,
316
+ namespace: "pepr-system",
317
+ },
318
+ type: "Opaque",
319
+ data: {
320
+ [path]: compressed.toString("base64"),
321
+ },
322
+ };
323
+ }
309
324
  }
310
325
 
311
326
  function genEnv(config: ModuleConfig, watchMode = false): V1EnvVar[] {
@@ -48,8 +48,8 @@ export async function allYaml(assets: Assets, rbacMode: string) {
48
48
  // Generate a hash of the code
49
49
  const hash = crypto.createHash("sha256").update(code).digest("hex");
50
50
 
51
- const mutateWebhook = await webhookConfig(assets, "mutate");
52
- const validateWebhook = await webhookConfig(assets, "validate");
51
+ const mutateWebhook = await webhookConfig(assets, "mutate", assets.config.webhookTimeout);
52
+ const validateWebhook = await webhookConfig(assets, "validate", assets.config.webhookTimeout);
53
53
  const watchDeployment = watcher(assets, hash);
54
54
 
55
55
  const resources = [
@@ -203,7 +203,7 @@ export class Capability implements CapabilityExport {
203
203
 
204
204
  const bindings = this.#bindings;
205
205
  const prefix = `${this.#name}: ${model.name}`;
206
- const commonChain = { WithLabel, WithAnnotation, Mutate, Validate, Watch };
206
+ const commonChain = { WithLabel, WithAnnotation, Mutate, Validate, Watch, Reconcile };
207
207
  const isNotEmpty = (value: object) => Object.keys(value).length > 0;
208
208
  const log = (message: string, cbString: string) => {
209
209
  const filteredObj = pickBy(isNotEmpty, binding.filters);
@@ -226,7 +226,7 @@ export class Capability implements CapabilityExport {
226
226
  });
227
227
  }
228
228
 
229
- return { Watch };
229
+ return { Watch, Reconcile };
230
230
  }
231
231
 
232
232
  function Mutate(mutateCallback: MutateAction<T>): MutateActionChain<T> {
@@ -243,7 +243,7 @@ export class Capability implements CapabilityExport {
243
243
  }
244
244
 
245
245
  // Now only allow adding actions to the same binding
246
- return { Watch, Validate };
246
+ return { Watch, Validate, Reconcile };
247
247
  }
248
248
 
249
249
  function Watch(watchCallback: WatchAction<T>) {
@@ -258,6 +258,19 @@ export class Capability implements CapabilityExport {
258
258
  }
259
259
  }
260
260
 
261
+ function Reconcile(watchCallback: WatchAction<T>) {
262
+ if (registerWatch) {
263
+ log("Reconcile Action", watchCallback.toString());
264
+
265
+ bindings.push({
266
+ ...binding,
267
+ isWatch: true,
268
+ isQueue: true,
269
+ watchCallback,
270
+ });
271
+ }
272
+ }
273
+
261
274
  function InNamespace(...namespaces: string[]): BindingWithName<T> {
262
275
  Log.debug(`Add namespaces filter ${namespaces}`, prefix);
263
276
  binding.filters.namespaces.push(...namespaces);
@@ -5,6 +5,7 @@ import { K8s, kind } from "kubernetes-fluent-client";
5
5
  import Log from "./logger";
6
6
  import { CapabilityExport } from "./types";
7
7
  import { promises as fs } from "fs";
8
+ import commander from "commander";
8
9
 
9
10
  type RBACMap = {
10
11
  [key: string]: {
@@ -161,3 +162,26 @@ export async function namespaceDeploymentsReady(namespace: string = "pepr-system
161
162
  }
162
163
  Log.info(`All ${namespace} deployments are ready`);
163
164
  }
165
+
166
+ // check if secret is over the size limit
167
+ export function secretOverLimit(str: string): boolean {
168
+ const encoder = new TextEncoder();
169
+ const encoded = encoder.encode(str);
170
+ const sizeInBytes = encoded.length;
171
+ const oneMiBInBytes = 1048576;
172
+ return sizeInBytes > oneMiBInBytes;
173
+ }
174
+
175
+ /* eslint-disable @typescript-eslint/no-unused-vars */
176
+ export const parseTimeout = (value: string, previous: unknown): number => {
177
+ const parsedValue = parseInt(value, 10);
178
+ const floatValue = parseFloat(value);
179
+ if (isNaN(parsedValue)) {
180
+ throw new commander.InvalidArgumentError("Not a number.");
181
+ } else if (parsedValue !== floatValue) {
182
+ throw new commander.InvalidArgumentError("Value must be an integer.");
183
+ } else if (parsedValue < 1 || parsedValue > 30) {
184
+ throw new commander.InvalidArgumentError("Number must be between 1 and 30.");
185
+ }
186
+ return parsedValue;
187
+ };
package/src/lib/module.ts CHANGED
@@ -23,6 +23,8 @@ export type ModuleConfig = {
23
23
  uuid: string;
24
24
  /** A description of the Pepr module and what it does. */
25
25
  description?: string;
26
+ /** The webhookTimeout */
27
+ webhookTimeout?: number;
26
28
  /** Reject K8s resource AdmissionRequests on error. */
27
29
  onError?: string;
28
30
  /** Configure global exclusions that will never be processed by Pepr. */
@@ -0,0 +1,77 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // SPDX-FileCopyrightText: 2023-Present The Pepr Authors
3
+ import { KubernetesObject } from "@kubernetes/client-node";
4
+ import Log from "./logger";
5
+
6
+ type QueueItem<K extends KubernetesObject> = {
7
+ item: K;
8
+ resolve: (value: void | PromiseLike<void>) => void;
9
+ reject: (reason?: string) => void;
10
+ };
11
+
12
+ /**
13
+ * Queue is a FIFO queue for reconciling
14
+ */
15
+ export class Queue<K extends KubernetesObject> {
16
+ #queue: QueueItem<K>[] = [];
17
+ #pendingPromise = false;
18
+ #reconcile?: (...args: unknown[]) => Promise<void>;
19
+
20
+ constructor() {
21
+ this.#reconcile = async () => await new Promise(resolve => resolve());
22
+ }
23
+
24
+ setReconcile(reconcile: (...args: unknown[]) => Promise<void>) {
25
+ this.#reconcile = reconcile;
26
+ }
27
+ /**
28
+ * Enqueue adds an item to the queue and returns a promise that resolves when the item is
29
+ * reconciled.
30
+ *
31
+ * @param item The object to reconcile
32
+ * @returns A promise that resolves when the object is reconciled
33
+ */
34
+ enqueue(item: K) {
35
+ Log.debug(`Enqueueing ${item.metadata!.namespace}/${item.metadata!.name}`);
36
+ return new Promise<void>((resolve, reject) => {
37
+ this.#queue.push({ item, resolve, reject });
38
+ return this.#dequeue();
39
+ });
40
+ }
41
+
42
+ /**
43
+ * Dequeue reconciles the next item in the queue
44
+ *
45
+ * @returns A promise that resolves when the webapp is reconciled
46
+ */
47
+ async #dequeue() {
48
+ // If there is a pending promise, do nothing
49
+ if (this.#pendingPromise) return false;
50
+
51
+ // Take the next element from the queue
52
+ const element = this.#queue.shift();
53
+
54
+ // If there is no element, do nothing
55
+ if (!element) return false;
56
+
57
+ try {
58
+ // Set the pending promise flag to avoid concurrent reconciliations
59
+ this.#pendingPromise = true;
60
+
61
+ // Reconcile the webapp
62
+ if (this.#reconcile) {
63
+ await this.#reconcile(element.item);
64
+ }
65
+
66
+ element.resolve();
67
+ } catch (e) {
68
+ element.reject(e);
69
+ } finally {
70
+ // Reset the pending promise flag
71
+ this.#pendingPromise = false;
72
+
73
+ // After the element is reconciled, dequeue the next element
74
+ await this.#dequeue();
75
+ }
76
+ }
77
+ }
package/src/lib/types.ts CHANGED
@@ -72,6 +72,7 @@ export type Binding = {
72
72
  isMutate?: boolean;
73
73
  isValidate?: boolean;
74
74
  isWatch?: boolean;
75
+ isQueue?: boolean;
75
76
  readonly model: GenericClass;
76
77
  readonly kind: GroupVersionKind;
77
78
  readonly filters: {
@@ -159,6 +160,19 @@ export type ValidateActionChain<T extends GenericClass> = {
159
160
  * @returns
160
161
  */
161
162
  Watch: (action: WatchAction<T, InstanceType<T>>) => void;
163
+
164
+ /**
165
+ * Establish a reconcile for the specified resource. The callback function will be executed after the admission controller has
166
+ * processed the resource and the request has been persisted to the cluster.
167
+ *
168
+ * **Beta Function**: This method is still in early testing and edge cases may still exist.
169
+ *
170
+ * @since 0.14.0
171
+ *
172
+ * @param action
173
+ * @returns
174
+ */
175
+ Reconcile: (action: WatchAction<T, InstanceType<T>>) => void;
162
176
  };
163
177
 
164
178
  export type MutateActionChain<T extends GenericClass> = ValidateActionChain<T> & {
@@ -4,11 +4,13 @@
4
4
  import { createHash } from "crypto";
5
5
  import { K8s, WatchCfg, WatchEvent } from "kubernetes-fluent-client";
6
6
  import { WatchPhase } from "kubernetes-fluent-client/dist/fluent/types";
7
-
7
+ import { Queue } from "./queue";
8
8
  import { Capability } from "./capability";
9
9
  import { PeprStore } from "./k8s";
10
10
  import Log from "./logger";
11
11
  import { Binding, Event } from "./types";
12
+ import { Watcher } from "kubernetes-fluent-client/dist/fluent/watch";
13
+ import { GenericClass } from "kubernetes-fluent-client";
12
14
 
13
15
  // Track if the store has been updated
14
16
  let storeUpdates = false;
@@ -80,21 +82,42 @@ async function runBinding(binding: Binding) {
80
82
  retryDelaySec: 5,
81
83
  };
82
84
 
83
- // Watch the resource
84
- const watcher = K8s(binding.model, binding.filters).Watch(async (obj, type) => {
85
- Log.debug(obj, `Watch event ${type} received`);
86
-
87
- // If the type matches the phase, call the watch callback
88
- if (phaseMatch.includes(type)) {
89
- try {
90
- // Perform the watch callback
91
- await binding.watchCallback?.(obj, type);
92
- } catch (e) {
93
- // Errors in the watch callback should not crash the controller
94
- Log.error(e, "Error executing watch callback");
85
+ let watcher: Watcher<GenericClass>;
86
+ if (binding.isQueue) {
87
+ const queue = new Queue();
88
+ // Watch the resource
89
+ watcher = K8s(binding.model, binding.filters).Watch(async (obj, type) => {
90
+ Log.debug(obj, `Watch event ${type} received`);
91
+
92
+ // If the type matches the phase, call the watch callback
93
+ if (phaseMatch.includes(type)) {
94
+ try {
95
+ queue.setReconcile(async () => await binding.watchCallback?.(obj, type));
96
+ // Enqueue the object for reconciliation through callback
97
+ await queue.enqueue(obj);
98
+ } catch (e) {
99
+ // Errors in the watch callback should not crash the controller
100
+ Log.error(e, "Error executing watch callback");
101
+ }
95
102
  }
96
- }
97
- }, watchCfg);
103
+ }, watchCfg);
104
+ } else {
105
+ // Watch the resource
106
+ watcher = K8s(binding.model, binding.filters).Watch(async (obj, type) => {
107
+ Log.debug(obj, `Watch event ${type} received`);
108
+
109
+ // If the type matches the phase, call the watch callback
110
+ if (phaseMatch.includes(type)) {
111
+ try {
112
+ // Perform the watch callback
113
+ await binding.watchCallback?.(obj, type);
114
+ } catch (e) {
115
+ // Errors in the watch callback should not crash the controller
116
+ Log.error(e, "Error executing watch callback");
117
+ }
118
+ }
119
+ }, watchCfg);
120
+ }
98
121
 
99
122
  // Create a unique cache ID for this watch binding in case multiple bindings are watching the same resource
100
123
  const cacheSuffix = createHash("sha224").update(binding.watchCallback!.toString()).digest("hex").substring(0, 5);