pepr 0.6.0 → 0.7.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,8 +2,8 @@
2
2
  // SPDX-FileCopyrightText: 2023-Present The Pepr Authors
3
3
 
4
4
  import {
5
- AdmissionregistrationV1Api,
6
- AdmissionregistrationV1WebhookClientConfig,
5
+ AdmissionregistrationV1Api as AdmissionRegV1API,
6
+ AdmissionregistrationV1WebhookClientConfig as AdmissionRegnV1WebhookClientCfg,
7
7
  AppsV1Api,
8
8
  CoreV1Api,
9
9
  HttpError,
@@ -17,15 +17,20 @@ import {
17
17
  V1MutatingWebhookConfiguration,
18
18
  V1Namespace,
19
19
  V1NetworkPolicy,
20
+ V1RuleWithOperations,
20
21
  V1Secret,
21
22
  V1Service,
22
23
  V1ServiceAccount,
23
24
  dumpYaml,
24
25
  } from "@kubernetes/client-node";
26
+ import { fork } from "child_process";
25
27
  import crypto from "crypto";
28
+ import { promises as fs } from "fs";
29
+ import { equals, uniqWith } from "ramda";
26
30
  import { gzipSync } from "zlib";
31
+
27
32
  import Log from "../logger";
28
- import { ModuleConfig } from "../types";
33
+ import { Binding, Event, HookPhase, ModuleConfig } from "../types";
29
34
  import { TLSOut, genTLS } from "./tls";
30
35
 
31
36
  const peprIgnore: V1LabelSelectorRequirement = {
@@ -132,7 +137,95 @@ export class Webhook {
132
137
  };
133
138
  }
134
139
 
135
- mutatingWebhook(timeoutSeconds = 10): V1MutatingWebhookConfiguration {
140
+ generateWebhookRules(path: string): Promise<V1RuleWithOperations[]> {
141
+ return new Promise((resolve, reject) => {
142
+ const rules: V1RuleWithOperations[] = [];
143
+
144
+ // Add a default rule that allows all resources as a fallback
145
+ const defaultRule = {
146
+ apiGroups: ["*"],
147
+ apiVersions: ["*"],
148
+ operations: ["CREATE", "UPDATE", "DELETE"],
149
+ resources: ["*/*"],
150
+ };
151
+
152
+ // Fork is needed with the PEPR_MODE env var to ensure the module is loaded in build mode and will send back the capabilities
153
+ const program = fork(path, {
154
+ env: {
155
+ ...process.env,
156
+ LOG_LEVEL: "warn",
157
+ PEPR_MODE: "build",
158
+ },
159
+ });
160
+
161
+ // We are receiving javscript so the private fields are now public
162
+ interface ModuleCapabilities {
163
+ capabilities: {
164
+ _name: string;
165
+ _description: string;
166
+ _namespaces: string[];
167
+ _mutateOrValidate: HookPhase;
168
+ _bindings: Binding[];
169
+ }[];
170
+ }
171
+
172
+ // Wait for the module to send back the capabilities
173
+ program.on("message", message => {
174
+ // Cast the message to the ModuleCapabilities type
175
+ const { capabilities } = message.valueOf() as ModuleCapabilities;
176
+
177
+ for (const capability of capabilities) {
178
+ Log.info(`Module ${this.config.uuid} has capability: ${capability._name}`);
179
+
180
+ const { _bindings } = capability;
181
+
182
+ // Read the bindings and generate the rules
183
+ for (const binding of _bindings) {
184
+ const { event, kind } = binding;
185
+
186
+ const operations: string[] = [];
187
+
188
+ // CreateOrUpdate is a Pepr-specific event that is translated to Create and Update
189
+ if (event === Event.CreateOrUpdate) {
190
+ operations.push(Event.Create, Event.Update);
191
+ } else {
192
+ operations.push(event);
193
+ }
194
+
195
+ // Use the plural property if it exists, otherwise use lowercase kind + s
196
+ const resource = kind.plural || `${kind.kind.toLowerCase()}s`;
197
+
198
+ rules.push({
199
+ apiGroups: [kind.group],
200
+ apiVersions: [kind.version || "*"],
201
+ operations,
202
+ resources: [resource],
203
+ });
204
+ }
205
+ }
206
+ });
207
+
208
+ program.on("exit", code => {
209
+ if (code !== 0) {
210
+ reject(new Error(`Child process exited with code ${code}`));
211
+ } else {
212
+ // If there are no rules, add a catch-all
213
+ if (rules.length < 1) {
214
+ resolve([defaultRule]);
215
+ } else {
216
+ const reducedRules = uniqWith(equals, rules);
217
+ resolve(reducedRules);
218
+ }
219
+ }
220
+ });
221
+
222
+ program.on("error", error => {
223
+ reject(error);
224
+ });
225
+ });
226
+ }
227
+
228
+ async mutatingWebhook(path: string, timeoutSeconds = 10): Promise<V1MutatingWebhookConfiguration> {
136
229
  const { name } = this;
137
230
  const ignore = [peprIgnore];
138
231
 
@@ -145,7 +238,7 @@ export class Webhook {
145
238
  });
146
239
  }
147
240
 
148
- const clientConfig: AdmissionregistrationV1WebhookClientConfig = {
241
+ const clientConfig: AdmissionRegnV1WebhookClientCfg = {
149
242
  caBundle: this._tls.ca,
150
243
  };
151
244
 
@@ -161,6 +254,8 @@ export class Webhook {
161
254
  };
162
255
  }
163
256
 
257
+ const rules = await this.generateWebhookRules(path);
258
+
164
259
  return {
165
260
  apiVersion: "admissionregistration.k8s.io/v1",
166
261
  kind: "MutatingWebhookConfiguration",
@@ -179,15 +274,7 @@ export class Webhook {
179
274
  objectSelector: {
180
275
  matchExpressions: ignore,
181
276
  },
182
- // @todo: make this configurable
183
- rules: [
184
- {
185
- apiGroups: ["*"],
186
- apiVersions: ["*"],
187
- operations: ["CREATE", "UPDATE", "DELETE"],
188
- resources: ["*/*"],
189
- },
190
- ],
277
+ rules,
191
278
  // @todo: track side effects state
192
279
  sideEffects: "None",
193
280
  },
@@ -390,10 +477,14 @@ export class Webhook {
390
477
  return dumpYaml(zarfCfg, { noRefs: true });
391
478
  }
392
479
 
393
- allYaml(code: Buffer) {
480
+ async allYaml(path: string) {
481
+ const code = await fs.readFile(path);
482
+
394
483
  // Generate a hash of the code
395
484
  const hash = crypto.createHash("sha256").update(code).digest("hex");
396
485
 
486
+ const webhook = await this.mutatingWebhook(path);
487
+
397
488
  const resources = [
398
489
  this.namespace(),
399
490
  this.networkPolicy(),
@@ -401,7 +492,7 @@ export class Webhook {
401
492
  this.clusterRoleBinding(),
402
493
  this.serviceAccount(),
403
494
  this.tlsSecret(),
404
- this.mutatingWebhook(),
495
+ webhook,
405
496
  this.deployment(hash),
406
497
  this.service(),
407
498
  this.moduleSecret(code, hash),
@@ -411,7 +502,7 @@ export class Webhook {
411
502
  return resources.map(r => dumpYaml(r, { noRefs: true })).join("---\n");
412
503
  }
413
504
 
414
- async deploy(code?: Buffer, webhookTimeout?: number) {
505
+ async deploy(path: string, webhookTimeout?: number) {
415
506
  Log.info("Establishing connection to Kubernetes");
416
507
 
417
508
  const namespace = "pepr-system";
@@ -421,10 +512,7 @@ export class Webhook {
421
512
  kubeConfig.loadFromDefault();
422
513
 
423
514
  const coreV1Api = kubeConfig.makeApiClient(CoreV1Api);
424
- const rbacApi = kubeConfig.makeApiClient(RbacAuthorizationV1Api);
425
- const appsApi = kubeConfig.makeApiClient(AppsV1Api);
426
- const admissionApi = kubeConfig.makeApiClient(AdmissionregistrationV1Api);
427
- const networkApi = kubeConfig.makeApiClient(NetworkingV1Api);
515
+ const admissionApi = kubeConfig.makeApiClient(AdmissionRegV1API);
428
516
 
429
517
  const ns = this.namespace();
430
518
  try {
@@ -436,7 +524,7 @@ export class Webhook {
436
524
  await coreV1Api.createNamespace(ns);
437
525
  }
438
526
 
439
- const wh = this.mutatingWebhook(webhookTimeout);
527
+ const wh = await this.mutatingWebhook(path, webhookTimeout);
440
528
  try {
441
529
  Log.info("Creating mutating webhook");
442
530
  await admissionApi.createMutatingWebhookConfiguration(wh);
@@ -452,20 +540,26 @@ export class Webhook {
452
540
  return;
453
541
  }
454
542
 
455
- if (!code) {
543
+ if (!path) {
456
544
  throw new Error("No code provided");
457
545
  }
458
546
 
547
+ const code = await fs.readFile(path);
548
+
459
549
  const hash = crypto.createHash("sha256").update(code).digest("hex");
460
550
 
461
- const netpol = this.networkPolicy();
551
+ const appsApi = kubeConfig.makeApiClient(AppsV1Api);
552
+ const rbacApi = kubeConfig.makeApiClient(RbacAuthorizationV1Api);
553
+ const networkApi = kubeConfig.makeApiClient(NetworkingV1Api);
554
+
555
+ const networkPolicy = this.networkPolicy();
462
556
  try {
463
557
  Log.info("Checking for network policy");
464
- await networkApi.readNamespacedNetworkPolicy(netpol.metadata?.name ?? "", namespace);
558
+ await networkApi.readNamespacedNetworkPolicy(networkPolicy.metadata?.name ?? "", namespace);
465
559
  } catch (e) {
466
560
  Log.debug(e instanceof HttpError ? e.body : e);
467
561
  Log.info("Creating network policy");
468
- await networkApi.createNamespacedNetworkPolicy(namespace, netpol);
562
+ await networkApi.createNamespacedNetworkPolicy(namespace, networkPolicy);
469
563
  }
470
564
 
471
565
  const crb = this.clusterRoleBinding();
package/src/lib/module.ts CHANGED
@@ -29,7 +29,7 @@ export type PeprModuleOptions = {
29
29
  };
30
30
 
31
31
  export class PeprModule {
32
- private _controller: Controller;
32
+ private _controller!: Controller;
33
33
 
34
34
  /**
35
35
  * Create a new Pepr runtime
@@ -42,6 +42,12 @@ export class PeprModule {
42
42
  const config: ModuleConfig = mergeDeepWith(concat, pepr, alwaysIgnore);
43
43
  config.description = description;
44
44
 
45
+ // Handle build mode
46
+ if (process.env.PEPR_MODE === "build") {
47
+ process.send?.({ capabilities });
48
+ return;
49
+ }
50
+
45
51
  this._controller = new Controller(config, capabilities, opts.beforeHook, opts.afterHook);
46
52
 
47
53
  // Stop processing if deferStart is set to true
@@ -7,11 +7,17 @@ import { Capability } from "./capability";
7
7
  import { shouldSkipRequest } from "./filter";
8
8
  import { Request, Response } from "./k8s/types";
9
9
  import { Secret } from "./k8s/upstream";
10
- import logger from "./logger";
11
- import { PeprRequest, convertToBase64Map } from "./request";
10
+ import Log from "./logger";
11
+ import { PeprRequest } from "./request";
12
12
  import { ModuleConfig } from "./types";
13
-
14
- export async function processor(config: ModuleConfig, capabilities: Capability[], req: Request): Promise<Response> {
13
+ import { convertFromBase64Map, convertToBase64Map } from "./utils";
14
+
15
+ export async function processor(
16
+ config: ModuleConfig,
17
+ capabilities: Capability[],
18
+ req: Request,
19
+ parentPrefix: string
20
+ ): Promise<Response> {
15
21
  const wrapped = new PeprRequest(req);
16
22
  const response: Response = {
17
23
  uid: req.uid,
@@ -19,11 +25,22 @@ export async function processor(config: ModuleConfig, capabilities: Capability[]
19
25
  allowed: false,
20
26
  };
21
27
 
22
- logger.info(`Processing '${req.uid}' for '${req.kind.kind}' '${req.name}'`);
28
+ // Track whether any capability matched the request
29
+ let matchedCapabilityAction = false;
30
+
31
+ // Track data fields that should be skipped during decoding
32
+ let skipDecode: string[] = [];
33
+
34
+ // If the resource is a secret, decode the data
35
+ const isSecret = req.kind.version == "v1" && req.kind.kind == "Secret";
36
+ if (isSecret) {
37
+ skipDecode = convertFromBase64Map(wrapped.Raw as unknown as Secret);
38
+ }
39
+
40
+ Log.info(`Processing request`, parentPrefix);
23
41
 
24
42
  for (const { name, bindings } of capabilities) {
25
- const prefix = `${req.uid} ${req.name}: ${name}`;
26
- logger.info(`Processing capability ${name}`, prefix);
43
+ const prefix = `${parentPrefix} ${name}:`;
27
44
 
28
45
  for (const action of bindings) {
29
46
  // Continue to the next action without doing anything if this one should be skipped
@@ -31,7 +48,10 @@ export async function processor(config: ModuleConfig, capabilities: Capability[]
31
48
  continue;
32
49
  }
33
50
 
34
- logger.info(`Processing matched action ${action.kind.kind}`, prefix);
51
+ const label = action.callback.name;
52
+ Log.info(`Processing matched action ${label}`, prefix);
53
+
54
+ matchedCapabilityAction = true;
35
55
 
36
56
  // Add annotations to the request to indicate that the capability started processing
37
57
  // this will allow tracking of failed mutations that were permitted to continue
@@ -53,7 +73,7 @@ export async function processor(config: ModuleConfig, capabilities: Capability[]
53
73
  // Run the action
54
74
  await action.callback(wrapped);
55
75
 
56
- logger.info(`Action succeeded`, prefix);
76
+ Log.info(`Action succeeded`, prefix);
57
77
 
58
78
  // Add annotations to the request to indicate that the capability succeeded
59
79
  updateStatus("succeeded");
@@ -64,11 +84,11 @@ export async function processor(config: ModuleConfig, capabilities: Capability[]
64
84
 
65
85
  // If errors are not allowed, note the failure in the Response
66
86
  if (config.onError) {
67
- logger.error(`Action failed: ${e}`, prefix);
87
+ Log.error(`Action failed: ${e}`, prefix);
68
88
  response.result = "Pepr module configured to reject on error";
69
89
  return response;
70
90
  } else {
71
- logger.warn(`Action failed: ${e}`, prefix);
91
+ Log.warn(`Action failed: ${e}`, prefix);
72
92
  updateStatus("warning");
73
93
  }
74
94
  }
@@ -78,11 +98,17 @@ export async function processor(config: ModuleConfig, capabilities: Capability[]
78
98
  // If we've made it this far, the request is allowed
79
99
  response.allowed = true;
80
100
 
101
+ // If no capability matched the request, exit early
102
+ if (!matchedCapabilityAction) {
103
+ Log.info(`No matching capability action found`, parentPrefix);
104
+ return response;
105
+ }
106
+
81
107
  const transformed = wrapped.Raw;
82
108
 
83
109
  // Post-process the Secret requests to convert it back to the original format
84
- if (req.kind.version == "v1" && req.kind.kind == "Secret") {
85
- convertToBase64Map(transformed as unknown as Secret);
110
+ if (isSecret) {
111
+ convertToBase64Map(transformed as unknown as Secret, skipDecode);
86
112
  }
87
113
 
88
114
  // Compare the original request to the modified request to get the patches
@@ -101,7 +127,7 @@ export async function processor(config: ModuleConfig, capabilities: Capability[]
101
127
  delete response.warnings;
102
128
  }
103
129
 
104
- logger.debug(patches);
130
+ Log.debug(patches, parentPrefix);
105
131
 
106
132
  return response;
107
133
  }
@@ -4,7 +4,6 @@
4
4
  import { clone, mergeDeepRight } from "ramda";
5
5
 
6
6
  import { KubernetesObject, Request } from "./k8s/types";
7
- import { Secret } from "./k8s/upstream";
8
7
  import { DeepPartial } from "./types";
9
8
 
10
9
  /**
@@ -49,11 +48,6 @@ export class PeprRequest<T extends KubernetesObject> {
49
48
  constructor(private _input: Request<T>) {
50
49
  // Deep clone the object to prevent mutation of the original object
51
50
  this.Raw = clone(_input.object);
52
-
53
- // If the resource is a secret, decode the data
54
- if (_input.kind.version == "v1" && _input.kind.kind == "Secret") {
55
- convertFromBase64Map(this.Raw as unknown as Secret);
56
- }
57
51
  }
58
52
 
59
53
  /**
@@ -141,17 +135,3 @@ export class PeprRequest<T extends KubernetesObject> {
141
135
  return this.Raw?.metadata?.annotations?.[key] !== undefined;
142
136
  }
143
137
  }
144
-
145
- export function convertToBase64Map(obj: { data?: Record<string, string> }) {
146
- obj.data = obj.data ?? {};
147
- for (const key in obj.data) {
148
- obj.data[key] = Buffer.from(obj.data[key]).toString("base64");
149
- }
150
- }
151
-
152
- export function convertFromBase64Map(obj: { data?: Record<string, string> }) {
153
- obj.data = obj.data ?? {};
154
- for (const key in obj.data) {
155
- obj.data[key] = Buffer.from(obj.data[key], "base64").toString("utf-8");
156
- }
157
- }
package/src/lib/types.ts CHANGED
@@ -44,6 +44,7 @@ export enum Event {
44
44
  Update = "UPDATE",
45
45
  Delete = "DELETE",
46
46
  CreateOrUpdate = "CREATEORUPDATE",
47
+ Any = "*",
47
48
  }
48
49
 
49
50
  export interface CapabilityCfg {
@@ -123,7 +124,7 @@ export type WhenSelector<T extends GenericClass> = {
123
124
  };
124
125
 
125
126
  export type Binding = {
126
- event?: Event;
127
+ event: Event;
127
128
  readonly kind: GroupVersionKind;
128
129
  readonly filters: {
129
130
  name: string;
@@ -0,0 +1,55 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // SPDX-FileCopyrightText: 2023-Present The Pepr Authors
3
+
4
+ import Log from "./logger";
5
+
6
+ /** Test if a string is ascii or not */
7
+ export const isAscii = /^[\s\x20-\x7E]*$/;
8
+
9
+ /**
10
+ * Encode all ascii values in a map to base64
11
+ * @param obj The object to encode
12
+ * @param skip A list of keys to skip encoding
13
+ */
14
+ export function convertToBase64Map(obj: { data?: Record<string, string> }, skip: string[]) {
15
+ obj.data = obj.data ?? {};
16
+ for (const key in obj.data) {
17
+ const value = obj.data[key];
18
+ // Only encode ascii values
19
+ obj.data[key] = skip.includes(key) ? value : base64Encode(value);
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Decode all ascii values in a map from base64 to utf-8
25
+ * @param obj The object to decode
26
+ * @returns A list of keys that were skipped
27
+ */
28
+ export function convertFromBase64Map(obj: { data?: Record<string, string> }) {
29
+ const skip: string[] = [];
30
+
31
+ obj.data = obj.data ?? {};
32
+ for (const key in obj.data) {
33
+ const decoded = base64Decode(obj.data[key]);
34
+ if (isAscii.test(decoded)) {
35
+ // Only decode ascii values
36
+ obj.data[key] = decoded;
37
+ } else {
38
+ skip.push(key);
39
+ }
40
+ }
41
+
42
+ Log.debug(`Non-ascii data detected in keys: ${skip}, skipping automatic base64 decoding`);
43
+
44
+ return skip;
45
+ }
46
+
47
+ /** Decode a base64 string */
48
+ export function base64Decode(data: string) {
49
+ return Buffer.from(data, "base64").toString("utf-8");
50
+ }
51
+
52
+ /** Encode a string to base64 */
53
+ export function base64Encode(data: string) {
54
+ return Buffer.from(data).toString("base64");
55
+ }
package/src/lib.ts CHANGED
@@ -7,6 +7,7 @@ import { RegisterKind, a } from "./lib/k8s/index";
7
7
  import Log from "./lib/logger";
8
8
  import { PeprModule } from "./lib/module";
9
9
  import { PeprRequest } from "./lib/request";
10
+ import * as PeprUtils from "./lib/utils";
10
11
 
11
12
  // Import type information for external packages
12
13
  import type * as KubernetesClientNode from "@kubernetes/client-node";
@@ -17,6 +18,7 @@ export {
17
18
  /** PeprModule is used to setup a complete Pepr Module: `new PeprModule(cfg, {...capabilities})` */
18
19
  PeprModule,
19
20
  PeprRequest,
21
+ PeprUtils,
20
22
  RegisterKind,
21
23
  Capability,
22
24
  Log,