pepr 0.24.1 → 0.26.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.
@@ -20,7 +20,7 @@ export async function deploy(assets: Assets, force: boolean, webhookTimeout?: nu
20
20
  const { name, host, path } = assets;
21
21
 
22
22
  Log.info("Applying pepr-system namespace");
23
- await K8s(kind.Namespace).Apply(namespace);
23
+ await K8s(kind.Namespace).Apply(namespace(assets.config.customLabels?.namespace));
24
24
 
25
25
  // Create the mutating webhook configuration if it is needed
26
26
  const mutateWebhook = await webhookConfig(assets, "mutate", webhookTimeout);
@@ -4,17 +4,22 @@
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";
11
11
 
12
12
  /** Generate the pepr-system namespace */
13
- export const namespace: kind.Namespace = {
14
- apiVersion: "v1",
15
- kind: "Namespace",
16
- metadata: { name: "pepr-system" },
17
- };
13
+ export function namespace(namespaceLabels?: Record<string, string>) {
14
+ return {
15
+ apiVersion: "v1",
16
+ kind: "Namespace",
17
+ metadata: {
18
+ name: "pepr-system",
19
+ labels: namespaceLabels ?? {},
20
+ },
21
+ };
22
+ }
18
23
 
19
24
  export function watcher(assets: Assets, hash: string) {
20
25
  const { name, image, capabilities, config } = assets;
@@ -45,9 +50,13 @@ export function watcher(assets: Assets, hash: string) {
45
50
  metadata: {
46
51
  name: app,
47
52
  namespace: "pepr-system",
53
+ annotations: {
54
+ "pepr.dev/description": config.description || "",
55
+ },
48
56
  labels: {
49
57
  app,
50
58
  "pepr.dev/controller": "watcher",
59
+ "pepr.dev/uuid": config.uuid,
51
60
  },
52
61
  },
53
62
  spec: {
@@ -168,9 +177,13 @@ export function deployment(assets: Assets, hash: string): kind.Deployment {
168
177
  metadata: {
169
178
  name,
170
179
  namespace: "pepr-system",
180
+ annotations: {
181
+ "pepr.dev/description": config.description || "",
182
+ },
171
183
  labels: {
172
184
  app,
173
185
  "pepr.dev/controller": "admission",
186
+ "pepr.dev/uuid": config.uuid,
174
187
  },
175
188
  },
176
189
  spec: {
@@ -294,18 +307,25 @@ export function moduleSecret(name: string, data: Buffer, hash: string): kind.Sec
294
307
  // Compress the data
295
308
  const compressed = gzipSync(data);
296
309
  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
- };
310
+ const compressedData = compressed.toString("base64");
311
+ if (secretOverLimit(compressedData)) {
312
+ const error = new Error(`Module secret for ${name} is over the 1MB limit`);
313
+ console.error("Uncaught Exception:", error);
314
+ process.exit(1);
315
+ } else {
316
+ return {
317
+ apiVersion: "v1",
318
+ kind: "Secret",
319
+ metadata: {
320
+ name: `${name}-module`,
321
+ namespace: "pepr-system",
322
+ },
323
+ type: "Opaque",
324
+ data: {
325
+ [path]: compressed.toString("base64"),
326
+ },
327
+ };
328
+ }
309
329
  }
310
330
 
311
331
  function genEnv(config: ModuleConfig, watchMode = false): V1EnvVar[] {
@@ -10,7 +10,6 @@ import { apiTokenSecret, service, tlsSecret, watcherService } from "./networking
10
10
  import { deployment, moduleSecret, namespace, watcher } from "./pods";
11
11
  import { clusterRole, clusterRoleBinding, serviceAccount, storeRole, storeRoleBinding } from "./rbac";
12
12
  import { webhookConfig } from "./webhooks";
13
- import { peprStoreCRD } from "./store";
14
13
 
15
14
  export function zarfYaml({ name, image, config }: Assets, path: string) {
16
15
  const zarfCfg = {
@@ -48,12 +47,12 @@ export async function allYaml(assets: Assets, rbacMode: string) {
48
47
  // Generate a hash of the code
49
48
  const hash = crypto.createHash("sha256").update(code).digest("hex");
50
49
 
51
- const mutateWebhook = await webhookConfig(assets, "mutate");
52
- const validateWebhook = await webhookConfig(assets, "validate");
50
+ const mutateWebhook = await webhookConfig(assets, "mutate", assets.config.webhookTimeout);
51
+ const validateWebhook = await webhookConfig(assets, "validate", assets.config.webhookTimeout);
53
52
  const watchDeployment = watcher(assets, hash);
54
53
 
55
54
  const resources = [
56
- namespace,
55
+ namespace(assets.config.customLabels?.namespace),
57
56
  clusterRole(name, assets.capabilities, rbacMode),
58
57
  clusterRoleBinding(name),
59
58
  serviceAccount(name),
@@ -63,7 +62,6 @@ export async function allYaml(assets: Assets, rbacMode: string) {
63
62
  service(name),
64
63
  watcherService(name),
65
64
  moduleSecret(name, code, hash),
66
- peprStoreCRD,
67
65
  storeRole(name),
68
66
  storeRoleBinding(name),
69
67
  ];
@@ -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);
@@ -249,16 +249,23 @@ export class Controller {
249
249
  response: kubeAdmissionResponse,
250
250
  });
251
251
  } else {
252
- kubeAdmissionResponse = {
253
- uid: responseList[0].uid,
254
- allowed: responseList.filter(r => !r.allowed).length === 0,
255
- status: {
256
- message: (responseList as ValidateResponse[])
257
- .filter(rl => !rl.allowed)
258
- .map(curr => curr.status?.message)
259
- .join("; "),
260
- },
261
- };
252
+ kubeAdmissionResponse =
253
+ responseList.length === 0
254
+ ? {
255
+ uid: request.uid,
256
+ allowed: true,
257
+ status: { message: "no in-scope validations -- allowed!" },
258
+ }
259
+ : {
260
+ uid: responseList[0].uid,
261
+ allowed: responseList.filter(r => !r.allowed).length === 0,
262
+ status: {
263
+ message: (responseList as ValidateResponse[])
264
+ .filter(rl => !rl.allowed)
265
+ .map(curr => curr.status?.message)
266
+ .join("; "),
267
+ },
268
+ };
262
269
  res.send({
263
270
  apiVersion: "admission.k8s.io/v1",
264
271
  kind: "AdmissionReview",
@@ -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
@@ -1,8 +1,6 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // SPDX-FileCopyrightText: 2023-Present The Pepr Authors
3
-
4
3
  import { clone } from "ramda";
5
-
6
4
  import { Capability } from "./capability";
7
5
  import { Controller } from "./controller";
8
6
  import { ValidateError } from "./errors";
@@ -11,6 +9,10 @@ import { CapabilityExport } from "./types";
11
9
  import { setupWatch } from "./watch-processor";
12
10
  import { Log } from "../lib";
13
11
 
12
+ /** Custom Labels Type for package.json */
13
+ export interface CustomLabels {
14
+ namespace?: Record<string, string>;
15
+ }
14
16
  /** Global configuration for the Pepr runtime. */
15
17
  export type ModuleConfig = {
16
18
  /** The user-defined name for the module */
@@ -23,6 +25,8 @@ export type ModuleConfig = {
23
25
  uuid: string;
24
26
  /** A description of the Pepr module and what it does. */
25
27
  description?: string;
28
+ /** The webhookTimeout */
29
+ webhookTimeout?: number;
26
30
  /** Reject K8s resource AdmissionRequests on error. */
27
31
  onError?: string;
28
32
  /** Configure global exclusions that will never be processed by Pepr. */
@@ -31,6 +35,8 @@ export type ModuleConfig = {
31
35
  logLevel?: string;
32
36
  /** Propagate env variables to in-cluster controllers */
33
37
  env?: Record<string, string>;
38
+ /** Custom Labels for Kubernetes Objects */
39
+ customLabels?: CustomLabels;
34
40
  };
35
41
 
36
42
  export type PackageJSON = {
@@ -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);
@@ -7,9 +7,10 @@ import { fork } from "child_process";
7
7
  import crypto from "crypto";
8
8
  import fs from "fs";
9
9
  import { gunzipSync } from "zlib";
10
-
10
+ import { K8s, kind } from "kubernetes-fluent-client";
11
11
  import Log from "../lib/logger";
12
12
  import { packageJSON } from "../templates/data.json";
13
+ import { peprStoreCRD } from "../lib/assets/store";
13
14
 
14
15
  const { version } = packageJSON;
15
16
 
@@ -67,5 +68,12 @@ Log.info(`Pepr Controller (v${version})`);
67
68
 
68
69
  const hash = process.argv[2];
69
70
 
70
- validateHash(hash);
71
- runModule(hash);
71
+ const startup = async () => {
72
+ Log.info("Applying the Pepr Store CRD if it doesn't exist");
73
+ await K8s(kind.CustomResourceDefinition).Apply(peprStoreCRD, { force: true });
74
+
75
+ validateHash(hash);
76
+ runModule(hash);
77
+ };
78
+
79
+ startup().catch(err => Log.error(err));
@@ -5,8 +5,11 @@
5
5
  "name": "Development Module",
6
6
  "uuid": "20e17cf6-a2e4-46b2-b626-75d88d96c88b",
7
7
  "description": "Development module for pepr",
8
- "onError": "ignore",
8
+ "onError": "reject",
9
9
  "logLevel": "debug",
10
+ "customLabels": {
11
+ "namespace": {}
12
+ },
10
13
  "alwaysIgnore": {
11
14
  "namespaces": [],
12
15
  "labels": []