pepr 0.0.0-development
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/.prettierignore +1 -0
- package/CODE_OF_CONDUCT.md +133 -0
- package/LICENSE +201 -0
- package/README.md +151 -0
- package/SECURITY.md +18 -0
- package/SUPPORT.md +16 -0
- package/codecov.yaml +19 -0
- package/commitlint.config.js +1 -0
- package/package.json +70 -0
- package/src/cli.ts +48 -0
- package/src/lib/assets/deploy.ts +122 -0
- package/src/lib/assets/destroy.ts +33 -0
- package/src/lib/assets/helm.ts +219 -0
- package/src/lib/assets/index.ts +175 -0
- package/src/lib/assets/loader.ts +41 -0
- package/src/lib/assets/networking.ts +89 -0
- package/src/lib/assets/pods.ts +353 -0
- package/src/lib/assets/rbac.ts +111 -0
- package/src/lib/assets/store.ts +49 -0
- package/src/lib/assets/webhooks.ts +147 -0
- package/src/lib/assets/yaml.ts +234 -0
- package/src/lib/capability.ts +314 -0
- package/src/lib/controller/index.ts +326 -0
- package/src/lib/controller/store.ts +219 -0
- package/src/lib/errors.ts +20 -0
- package/src/lib/filter.ts +110 -0
- package/src/lib/helpers.ts +342 -0
- package/src/lib/included-files.ts +19 -0
- package/src/lib/k8s.ts +169 -0
- package/src/lib/logger.ts +27 -0
- package/src/lib/metrics.ts +120 -0
- package/src/lib/module.ts +136 -0
- package/src/lib/mutate-processor.ts +160 -0
- package/src/lib/mutate-request.ts +153 -0
- package/src/lib/queue.ts +89 -0
- package/src/lib/schedule.ts +175 -0
- package/src/lib/storage.ts +192 -0
- package/src/lib/tls.ts +90 -0
- package/src/lib/types.ts +215 -0
- package/src/lib/utils.ts +57 -0
- package/src/lib/validate-processor.ts +80 -0
- package/src/lib/validate-request.ts +102 -0
- package/src/lib/watch-processor.ts +124 -0
- package/src/lib.ts +27 -0
- package/src/runtime/controller.ts +75 -0
- package/src/sdk/sdk.ts +116 -0
- package/src/templates/.eslintrc.template.json +18 -0
- package/src/templates/.prettierrc.json +13 -0
- package/src/templates/README.md +21 -0
- package/src/templates/capabilities/hello-pepr.samples.json +160 -0
- package/src/templates/capabilities/hello-pepr.ts +426 -0
- package/src/templates/gitignore +4 -0
- package/src/templates/package.json +20 -0
- package/src/templates/pepr.code-snippets.json +21 -0
- package/src/templates/pepr.ts +17 -0
- package/src/templates/settings.json +10 -0
- package/src/templates/tsconfig.json +9 -0
- package/src/templates/tsconfig.module.json +19 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// SPDX-FileCopyrightText: 2023-Present The Pepr Authors
|
|
3
|
+
|
|
4
|
+
import { promises as fs } from "fs";
|
|
5
|
+
import { K8s, KubernetesObject, kind } from "kubernetes-fluent-client";
|
|
6
|
+
import Log from "./logger";
|
|
7
|
+
import { Binding, CapabilityExport } from "./types";
|
|
8
|
+
import { sanitizeResourceName } from "../sdk/sdk";
|
|
9
|
+
|
|
10
|
+
export class ValidationError extends Error {}
|
|
11
|
+
|
|
12
|
+
export function validateCapabilityNames(capabilities: CapabilityExport[] | undefined): void {
|
|
13
|
+
if (capabilities && capabilities.length > 0) {
|
|
14
|
+
for (let i = 0; i < capabilities.length; i++) {
|
|
15
|
+
if (capabilities[i].name !== sanitizeResourceName(capabilities[i].name)) {
|
|
16
|
+
throw new ValidationError(`Capability name is not a valid Kubernetes resource name: ${capabilities[i].name}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function validateHash(expectedHash: string): void {
|
|
23
|
+
// Require the hash to be a valid SHA-256 hash (64 characters, hexadecimal)
|
|
24
|
+
const sha256Regex = /^[a-f0-9]{64}$/i;
|
|
25
|
+
if (!expectedHash || !sha256Regex.test(expectedHash)) {
|
|
26
|
+
Log.error(`Invalid hash. Expected a valid SHA-256 hash, got ${expectedHash}`);
|
|
27
|
+
throw new ValidationError("Invalid hash");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type RBACMap = {
|
|
32
|
+
[key: string]: {
|
|
33
|
+
verbs: string[];
|
|
34
|
+
plural: string;
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// check for overlap with labels and annotations between bindings and kubernetes objects
|
|
39
|
+
export function checkOverlap(bindingFilters: Record<string, string>, objectFilters: Record<string, string>): boolean {
|
|
40
|
+
// True if labels/annotations are empty
|
|
41
|
+
if (Object.keys(bindingFilters).length === 0) {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let matchCount = 0;
|
|
46
|
+
|
|
47
|
+
for (const key in bindingFilters) {
|
|
48
|
+
// object must have label/annotation
|
|
49
|
+
if (Object.prototype.hasOwnProperty.call(objectFilters, key)) {
|
|
50
|
+
const val1 = bindingFilters[key];
|
|
51
|
+
const val2 = objectFilters[key];
|
|
52
|
+
|
|
53
|
+
// If bindingFilter has empty value for this key, only need to ensure objectFilter has this key
|
|
54
|
+
if (val1 === "" && key in objectFilters) {
|
|
55
|
+
matchCount++;
|
|
56
|
+
}
|
|
57
|
+
// If bindingFilter has a value, it must match the value in objectFilter
|
|
58
|
+
else if (val1 !== "" && val1 === val2) {
|
|
59
|
+
matchCount++;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// For single-key objects in bindingFilter or matching all keys in multiple-keys scenario
|
|
65
|
+
return matchCount === Object.keys(bindingFilters).length;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Decide to run callback after the event comes back from API Server
|
|
70
|
+
**/
|
|
71
|
+
export function filterNoMatchReason(
|
|
72
|
+
binding: Partial<Binding>,
|
|
73
|
+
obj: Partial<KubernetesObject>,
|
|
74
|
+
capabilityNamespaces: string[],
|
|
75
|
+
): string {
|
|
76
|
+
// binding kind is namespace with a InNamespace filter
|
|
77
|
+
if (binding.kind && binding.kind.kind === "Namespace" && binding.filters && binding.filters.namespaces.length !== 0) {
|
|
78
|
+
return `Ignoring Watch Callback: Cannot use a namespace filter in a namespace object.`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (typeof obj === "object" && obj !== null && "metadata" in obj && obj.metadata !== undefined && binding.filters) {
|
|
82
|
+
// binding labels and object labels dont match
|
|
83
|
+
if (obj.metadata.labels && !checkOverlap(binding.filters.labels, obj.metadata.labels)) {
|
|
84
|
+
return `Ignoring Watch Callback: No overlap between binding and object labels. Binding labels ${JSON.stringify(
|
|
85
|
+
binding.filters.labels,
|
|
86
|
+
)}, Object Labels ${JSON.stringify(obj.metadata.labels)}.`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// binding annotations and object annotations dont match
|
|
90
|
+
if (obj.metadata.annotations && !checkOverlap(binding.filters.annotations, obj.metadata.annotations)) {
|
|
91
|
+
return `Ignoring Watch Callback: No overlap between binding and object annotations. Binding annotations ${JSON.stringify(
|
|
92
|
+
binding.filters.annotations,
|
|
93
|
+
)}, Object annotations ${JSON.stringify(obj.metadata.annotations)}.`;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Check object is in the capability namespace
|
|
98
|
+
if (
|
|
99
|
+
Array.isArray(capabilityNamespaces) &&
|
|
100
|
+
capabilityNamespaces.length > 0 &&
|
|
101
|
+
obj.metadata &&
|
|
102
|
+
obj.metadata.namespace &&
|
|
103
|
+
!capabilityNamespaces.includes(obj.metadata.namespace)
|
|
104
|
+
) {
|
|
105
|
+
return `Ignoring Watch Callback: Object is not in the capability namespace. Capability namespaces: ${capabilityNamespaces.join(
|
|
106
|
+
", ",
|
|
107
|
+
)}, Object namespace: ${obj.metadata.namespace}.`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// chceck every filter namespace is a capability namespace
|
|
111
|
+
if (
|
|
112
|
+
Array.isArray(capabilityNamespaces) &&
|
|
113
|
+
capabilityNamespaces.length > 0 &&
|
|
114
|
+
binding.filters &&
|
|
115
|
+
Array.isArray(binding.filters.namespaces) &&
|
|
116
|
+
binding.filters.namespaces.length > 0 &&
|
|
117
|
+
!binding.filters.namespaces.every(ns => capabilityNamespaces.includes(ns))
|
|
118
|
+
) {
|
|
119
|
+
return `Ignoring Watch Callback: Binding namespace is not part of capability namespaces. Capability namespaces: ${capabilityNamespaces.join(
|
|
120
|
+
", ",
|
|
121
|
+
)}, Binding namespaces: ${binding.filters.namespaces.join(", ")}.`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// filter namespace is not the same of object namespace
|
|
125
|
+
if (
|
|
126
|
+
binding.filters &&
|
|
127
|
+
Array.isArray(binding.filters.namespaces) &&
|
|
128
|
+
binding.filters.namespaces.length > 0 &&
|
|
129
|
+
obj.metadata &&
|
|
130
|
+
obj.metadata.namespace &&
|
|
131
|
+
!binding.filters.namespaces.includes(obj.metadata.namespace)
|
|
132
|
+
) {
|
|
133
|
+
return `Ignoring Watch Callback: Binding namespace and object namespace are not the same. Binding namespaces: ${binding.filters.namespaces.join(
|
|
134
|
+
", ",
|
|
135
|
+
)}, Object namespace: ${obj.metadata.namespace}.`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// no problems
|
|
139
|
+
return "";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function addVerbIfNotExists(verbs: string[], verb: string) {
|
|
143
|
+
if (!verbs.includes(verb)) {
|
|
144
|
+
verbs.push(verb);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function createRBACMap(capabilities: CapabilityExport[]): RBACMap {
|
|
149
|
+
return capabilities.reduce((acc: RBACMap, capability: CapabilityExport) => {
|
|
150
|
+
capability.bindings.forEach(binding => {
|
|
151
|
+
const key = `${binding.kind.group}/${binding.kind.version}/${binding.kind.kind}`;
|
|
152
|
+
|
|
153
|
+
acc["pepr.dev/v1/peprstore"] = {
|
|
154
|
+
verbs: ["create", "get", "patch", "watch"],
|
|
155
|
+
plural: "peprstores",
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
acc["apiextensions.k8s.io/v1/customresourcedefinition"] = {
|
|
159
|
+
verbs: ["patch", "create"],
|
|
160
|
+
plural: "customresourcedefinitions",
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
if (!acc[key] && binding.isWatch) {
|
|
164
|
+
acc[key] = {
|
|
165
|
+
verbs: ["watch"],
|
|
166
|
+
plural: binding.kind.plural || `${binding.kind.kind.toLowerCase()}s`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
return acc;
|
|
172
|
+
}, {});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function createDirectoryIfNotExists(path: string) {
|
|
176
|
+
try {
|
|
177
|
+
await fs.access(path);
|
|
178
|
+
} catch (error) {
|
|
179
|
+
if (error.code === "ENOENT") {
|
|
180
|
+
await fs.mkdir(path, { recursive: true });
|
|
181
|
+
} else {
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function hasEveryOverlap<T>(array1: T[], array2: T[]): boolean {
|
|
188
|
+
if (!Array.isArray(array1) || !Array.isArray(array2)) {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return array1.every(element => array2.includes(element));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function hasAnyOverlap<T>(array1: T[], array2: T[]): boolean {
|
|
196
|
+
if (!Array.isArray(array1) || !Array.isArray(array2)) {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return array1.some(element => array2.includes(element));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function ignoredNamespaceConflict(ignoreNamespaces: string[], bindingNamespaces: string[]) {
|
|
204
|
+
return hasAnyOverlap(bindingNamespaces, ignoreNamespaces);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function bindingAndCapabilityNSConflict(bindingNamespaces: string[], capabilityNamespaces: string[]) {
|
|
208
|
+
if (!capabilityNamespaces) {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
return capabilityNamespaces.length !== 0 && !hasEveryOverlap(bindingNamespaces, capabilityNamespaces);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function generateWatchNamespaceError(
|
|
215
|
+
ignoredNamespaces: string[],
|
|
216
|
+
bindingNamespaces: string[],
|
|
217
|
+
capabilityNamespaces: string[],
|
|
218
|
+
) {
|
|
219
|
+
let err = "";
|
|
220
|
+
|
|
221
|
+
// check if binding uses an ignored namespace
|
|
222
|
+
if (ignoredNamespaceConflict(ignoredNamespaces, bindingNamespaces)) {
|
|
223
|
+
err += `Binding uses a Pepr ignored namespace: ignoredNamespaces: [${ignoredNamespaces.join(
|
|
224
|
+
", ",
|
|
225
|
+
)}] bindingNamespaces: [${bindingNamespaces.join(", ")}].`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ensure filter namespaces are part of capability namespaces
|
|
229
|
+
if (bindingAndCapabilityNSConflict(bindingNamespaces, capabilityNamespaces)) {
|
|
230
|
+
err += `Binding uses namespace not governed by capability: bindingNamespaces: [${bindingNamespaces.join(
|
|
231
|
+
", ",
|
|
232
|
+
)}] capabilityNamespaces: [${capabilityNamespaces.join(", ")}].`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// add a space if there is a period in the middle of the string
|
|
236
|
+
return err.replace(/\.([^ ])/g, ". $1");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// namespaceComplianceValidator ensures that capability bindinds respect ignored and capability namespaces
|
|
240
|
+
export function namespaceComplianceValidator(capability: CapabilityExport, ignoredNamespaces?: string[]) {
|
|
241
|
+
const { namespaces: capabilityNamespaces, bindings, name } = capability;
|
|
242
|
+
const bindingNamespaces = bindings.flatMap(binding => binding.filters.namespaces);
|
|
243
|
+
|
|
244
|
+
const namespaceError = generateWatchNamespaceError(
|
|
245
|
+
ignoredNamespaces ? ignoredNamespaces : [],
|
|
246
|
+
bindingNamespaces,
|
|
247
|
+
capabilityNamespaces ? capabilityNamespaces : [],
|
|
248
|
+
);
|
|
249
|
+
if (namespaceError !== "") {
|
|
250
|
+
throw new Error(
|
|
251
|
+
`Error in ${name} capability. A binding violates namespace rules. Please check ignoredNamespaces and capability namespaces: ${namespaceError}`,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// check to see if all replicas are ready for all deployments in the pepr-system namespace
|
|
257
|
+
// returns true if all deployments are ready, false otherwise
|
|
258
|
+
export async function checkDeploymentStatus(namespace: string) {
|
|
259
|
+
const deployments = await K8s(kind.Deployment).InNamespace(namespace).Get();
|
|
260
|
+
let status = false;
|
|
261
|
+
let readyCount = 0;
|
|
262
|
+
|
|
263
|
+
for (const deployment of deployments.items) {
|
|
264
|
+
const readyReplicas = deployment.status?.readyReplicas ? deployment.status?.readyReplicas : 0;
|
|
265
|
+
if (deployment.status?.readyReplicas !== deployment.spec?.replicas) {
|
|
266
|
+
Log.info(
|
|
267
|
+
`Waiting for deployment ${deployment.metadata?.name} rollout to finish: ${readyReplicas} of ${deployment.spec?.replicas} replicas are available`,
|
|
268
|
+
);
|
|
269
|
+
} else {
|
|
270
|
+
Log.info(
|
|
271
|
+
`Deployment ${deployment.metadata?.name} rolled out: ${readyReplicas} of ${deployment.spec?.replicas} replicas are available`,
|
|
272
|
+
);
|
|
273
|
+
readyCount++;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (readyCount === deployments.items.length) {
|
|
277
|
+
status = true;
|
|
278
|
+
}
|
|
279
|
+
return status;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// wait for all deployments in the pepr-system namespace to be ready
|
|
283
|
+
export async function namespaceDeploymentsReady(namespace: string = "pepr-system") {
|
|
284
|
+
Log.info(`Checking ${namespace} deployments status...`);
|
|
285
|
+
let ready = false;
|
|
286
|
+
while (!ready) {
|
|
287
|
+
ready = await checkDeploymentStatus(namespace);
|
|
288
|
+
if (ready) {
|
|
289
|
+
return ready;
|
|
290
|
+
}
|
|
291
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
292
|
+
}
|
|
293
|
+
Log.info(`All ${namespace} deployments are ready`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// check if secret is over the size limit
|
|
297
|
+
export function secretOverLimit(str: string): boolean {
|
|
298
|
+
const encoder = new TextEncoder();
|
|
299
|
+
const encoded = encoder.encode(str);
|
|
300
|
+
const sizeInBytes = encoded.length;
|
|
301
|
+
const oneMiBInBytes = 1048576;
|
|
302
|
+
return sizeInBytes > oneMiBInBytes;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
306
|
+
export const parseTimeout = (value: string, previous: unknown): number => {
|
|
307
|
+
const parsedValue = parseInt(value, 10);
|
|
308
|
+
const floatValue = parseFloat(value);
|
|
309
|
+
if (isNaN(parsedValue)) {
|
|
310
|
+
throw new Error("Not a number.");
|
|
311
|
+
} else if (parsedValue !== floatValue) {
|
|
312
|
+
throw new Error("Value must be an integer.");
|
|
313
|
+
} else if (parsedValue < 1 || parsedValue > 30) {
|
|
314
|
+
throw new Error("Number must be between 1 and 30.");
|
|
315
|
+
}
|
|
316
|
+
return parsedValue;
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
// Remove leading whitespace while keeping format of file
|
|
320
|
+
export function dedent(file: string) {
|
|
321
|
+
// Check if the first line is empty and remove it
|
|
322
|
+
const lines = file.split("\n");
|
|
323
|
+
if (lines[0].trim() === "") {
|
|
324
|
+
lines.shift(); // Remove the first line if it's empty
|
|
325
|
+
file = lines.join("\n"); // Rejoin the remaining lines back into a single string
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const match = file.match(/^[ \t]*(?=\S)/gm);
|
|
329
|
+
const indent = match && Math.min(...match.map(el => el.length));
|
|
330
|
+
if (indent && indent > 0) {
|
|
331
|
+
const re = new RegExp(`^[ \\t]{${indent}}`, "gm");
|
|
332
|
+
return file.replace(re, "");
|
|
333
|
+
}
|
|
334
|
+
return file;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export function replaceString(str: string, stringA: string, stringB: string) {
|
|
338
|
+
//eslint-disable-next-line
|
|
339
|
+
const escapedStringA = stringA.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&");
|
|
340
|
+
const regExp = new RegExp(escapedStringA, "g");
|
|
341
|
+
return str.replace(regExp, stringB);
|
|
342
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// SPDX-FileCopyrightText: 2023-Present The Pepr Authors
|
|
3
|
+
|
|
4
|
+
import { promises as fs } from "fs";
|
|
5
|
+
|
|
6
|
+
export async function createDockerfile(version: string, description: string, includedFiles: string[]) {
|
|
7
|
+
const file = `
|
|
8
|
+
# Use an official Node.js runtime as the base image
|
|
9
|
+
FROM ghcr.io/defenseunicorns/pepr/controller:v${version}
|
|
10
|
+
|
|
11
|
+
LABEL description="${description}"
|
|
12
|
+
|
|
13
|
+
# Add the included files to the image
|
|
14
|
+
${includedFiles.map(f => `ADD ${f} ${f}`).join("\n")}
|
|
15
|
+
|
|
16
|
+
`;
|
|
17
|
+
|
|
18
|
+
await fs.writeFile("Dockerfile.controller", file, { encoding: "utf-8" });
|
|
19
|
+
}
|
package/src/lib/k8s.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// SPDX-FileCopyrightText: 2023-Present The Pepr Authors
|
|
3
|
+
|
|
4
|
+
import { GenericKind, GroupVersionKind, KubernetesObject, RegisterKind } from "kubernetes-fluent-client";
|
|
5
|
+
|
|
6
|
+
export enum Operation {
|
|
7
|
+
CREATE = "CREATE",
|
|
8
|
+
UPDATE = "UPDATE",
|
|
9
|
+
DELETE = "DELETE",
|
|
10
|
+
CONNECT = "CONNECT",
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* PeprStore for internal use by Pepr. This is used to store arbitrary data in the cluster.
|
|
15
|
+
*/
|
|
16
|
+
export class PeprStore extends GenericKind {
|
|
17
|
+
declare data: {
|
|
18
|
+
[key: string]: string;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const peprStoreGVK = {
|
|
23
|
+
kind: "PeprStore",
|
|
24
|
+
version: "v1",
|
|
25
|
+
group: "pepr.dev",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
RegisterKind(PeprStore, peprStoreGVK);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* GroupVersionResource unambiguously identifies a resource. It doesn't anonymously include GroupVersion
|
|
32
|
+
* to avoid automatic coercion. It doesn't use a GroupVersion to avoid custom marshalling
|
|
33
|
+
*/
|
|
34
|
+
export interface GroupVersionResource {
|
|
35
|
+
readonly group: string;
|
|
36
|
+
readonly version: string;
|
|
37
|
+
readonly resource: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A Kubernetes admission request to be processed by a capability.
|
|
42
|
+
*/
|
|
43
|
+
export interface AdmissionRequest<T = KubernetesObject> {
|
|
44
|
+
/** UID is an identifier for the individual request/response. */
|
|
45
|
+
readonly uid: string;
|
|
46
|
+
|
|
47
|
+
/** Kind is the fully-qualified type of object being submitted (for example, v1.Pod or autoscaling.v1.Scale) */
|
|
48
|
+
readonly kind: GroupVersionKind;
|
|
49
|
+
|
|
50
|
+
/** Resource is the fully-qualified resource being requested (for example, v1.pods) */
|
|
51
|
+
readonly resource: GroupVersionResource;
|
|
52
|
+
|
|
53
|
+
/** SubResource is the sub-resource being requested, if any (for example, "status" or "scale") */
|
|
54
|
+
readonly subResource?: string;
|
|
55
|
+
|
|
56
|
+
/** RequestKind is the fully-qualified type of the original API request (for example, v1.Pod or autoscaling.v1.Scale). */
|
|
57
|
+
readonly requestKind?: GroupVersionKind;
|
|
58
|
+
|
|
59
|
+
/** RequestResource is the fully-qualified resource of the original API request (for example, v1.pods). */
|
|
60
|
+
readonly requestResource?: GroupVersionResource;
|
|
61
|
+
|
|
62
|
+
/** RequestSubResource is the sub-resource of the original API request, if any (for example, "status" or "scale"). */
|
|
63
|
+
readonly requestSubResource?: string;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Name is the name of the object as presented in the request. On a CREATE operation, the client may omit name and
|
|
67
|
+
* rely on the server to generate the name. If that is the case, this method will return the empty string.
|
|
68
|
+
*/
|
|
69
|
+
readonly name: string;
|
|
70
|
+
|
|
71
|
+
/** Namespace is the namespace associated with the request (if any). */
|
|
72
|
+
readonly namespace?: string;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Operation is the operation being performed. This may be different than the operation
|
|
76
|
+
* requested. e.g. a patch can result in either a CREATE or UPDATE Operation.
|
|
77
|
+
*/
|
|
78
|
+
readonly operation: Operation;
|
|
79
|
+
|
|
80
|
+
/** UserInfo is information about the requesting user */
|
|
81
|
+
readonly userInfo: {
|
|
82
|
+
/** The name that uniquely identifies this user among all active users. */
|
|
83
|
+
username?: string;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A unique value that identifies this user across time. If this user is deleted
|
|
87
|
+
* and another user by the same name is added, they will have different UIDs.
|
|
88
|
+
*/
|
|
89
|
+
uid?: string;
|
|
90
|
+
|
|
91
|
+
/** The names of groups this user is a part of. */
|
|
92
|
+
groups?: string[];
|
|
93
|
+
|
|
94
|
+
/** Any additional information provided by the authenticator. */
|
|
95
|
+
extra?: {
|
|
96
|
+
[key: string]: string[];
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** Object is the object from the incoming request prior to default values being applied */
|
|
101
|
+
readonly object: T;
|
|
102
|
+
|
|
103
|
+
/** OldObject is the existing object. Only populated for UPDATE or DELETE requests. */
|
|
104
|
+
readonly oldObject?: T;
|
|
105
|
+
|
|
106
|
+
/** DryRun indicates that modifications will definitely not be persisted for this request. Defaults to false. */
|
|
107
|
+
readonly dryRun?: boolean;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Options contains the options for the operation being performed.
|
|
111
|
+
* e.g. `meta.k8s.io/v1.DeleteOptions` or `meta.k8s.io/v1.CreateOptions`. This may be
|
|
112
|
+
* different than the options the caller provided. e.g. for a patch request the performed
|
|
113
|
+
* Operation might be a CREATE, in which case the Options will a
|
|
114
|
+
* `meta.k8s.io/v1.CreateOptions` even though the caller provided `meta.k8s.io/v1.PatchOptions`.
|
|
115
|
+
*/
|
|
116
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
117
|
+
readonly options?: any;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface MutateResponse {
|
|
121
|
+
/** UID is an identifier for the individual request/response. This must be copied over from the corresponding AdmissionRequest. */
|
|
122
|
+
uid: string;
|
|
123
|
+
|
|
124
|
+
/** Allowed indicates whether or not the admission request was permitted. */
|
|
125
|
+
allowed: boolean;
|
|
126
|
+
|
|
127
|
+
/** Result contains extra details into why an admission request was denied. This field IS NOT consulted in any way if "Allowed" is "true". */
|
|
128
|
+
result?: string;
|
|
129
|
+
|
|
130
|
+
/** The patch body. Currently we only support "JSONPatch" which implements RFC 6902. */
|
|
131
|
+
patch?: string;
|
|
132
|
+
|
|
133
|
+
/** The type of Patch. Currently we only allow "JSONPatch". */
|
|
134
|
+
patchType?: "JSONPatch";
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* AuditAnnotations is an unstructured key value map set by remote admission controller (e.g. error=image-blacklisted).
|
|
138
|
+
*
|
|
139
|
+
* See https://kubernetes.io/docs/reference/labels-annotations-taints/audit-annotations/ for more information
|
|
140
|
+
*/
|
|
141
|
+
auditAnnotations?: {
|
|
142
|
+
[key: string]: string;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** warnings is a list of warning messages to return to the requesting API client. */
|
|
146
|
+
warnings?: string[];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface ValidateResponse extends MutateResponse {
|
|
150
|
+
/** Status contains extra details into why an admission request was denied. This field IS NOT consulted in any way if "Allowed" is "true". */
|
|
151
|
+
status?: {
|
|
152
|
+
/** A machine-readable description of why this operation is in the
|
|
153
|
+
"Failure" status. If this value is empty there is no information available. */
|
|
154
|
+
code: number;
|
|
155
|
+
|
|
156
|
+
/** A human-readable description of the status of this operation. */
|
|
157
|
+
message: string;
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export type WebhookIgnore = {
|
|
162
|
+
/**
|
|
163
|
+
* List of Kubernetes namespaces to always ignore.
|
|
164
|
+
* Any resources in these namespaces will be ignored by Pepr.
|
|
165
|
+
*
|
|
166
|
+
* Note: `kube-system` and `pepr-system` are always ignored.
|
|
167
|
+
*/
|
|
168
|
+
namespaces?: string[];
|
|
169
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// SPDX-FileCopyrightText: 2023-Present The Pepr Authors
|
|
3
|
+
|
|
4
|
+
import { pino, stdTimeFunctions } from "pino";
|
|
5
|
+
|
|
6
|
+
const isPrettyLog = process.env.PEPR_PRETTY_LOGS === "true";
|
|
7
|
+
|
|
8
|
+
const pretty = {
|
|
9
|
+
target: "pino-pretty",
|
|
10
|
+
options: {
|
|
11
|
+
colorize: true,
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const transport = isPrettyLog ? pretty : undefined;
|
|
16
|
+
// epochTime is the pino default
|
|
17
|
+
const pinoTimeFunction =
|
|
18
|
+
process.env.PINO_TIME_STAMP === "iso" ? () => stdTimeFunctions.isoTime() : () => stdTimeFunctions.epochTime();
|
|
19
|
+
const Log = pino({
|
|
20
|
+
transport,
|
|
21
|
+
timestamp: pinoTimeFunction,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
if (process.env.LOG_LEVEL) {
|
|
25
|
+
Log.level = process.env.LOG_LEVEL;
|
|
26
|
+
}
|
|
27
|
+
export default Log;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// SPDX-FileCopyrightText: 2023-Present The Pepr Authors
|
|
3
|
+
|
|
4
|
+
/* eslint-disable class-methods-use-this */
|
|
5
|
+
|
|
6
|
+
import { performance } from "perf_hooks";
|
|
7
|
+
import promClient, { Counter, Registry, Summary } from "prom-client";
|
|
8
|
+
import Log from "./logger";
|
|
9
|
+
|
|
10
|
+
const loggingPrefix = "MetricsCollector";
|
|
11
|
+
|
|
12
|
+
interface MetricNames {
|
|
13
|
+
errors: string;
|
|
14
|
+
alerts: string;
|
|
15
|
+
mutate: string;
|
|
16
|
+
validate: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface MetricArgs {
|
|
20
|
+
name: string;
|
|
21
|
+
help: string;
|
|
22
|
+
registers: Registry[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* MetricsCollector class handles metrics collection using prom-client and performance hooks.
|
|
27
|
+
*/
|
|
28
|
+
export class MetricsCollector {
|
|
29
|
+
#registry: Registry;
|
|
30
|
+
#counters: Map<string, Counter<string>> = new Map();
|
|
31
|
+
#summaries: Map<string, Summary<string>> = new Map();
|
|
32
|
+
#prefix: string;
|
|
33
|
+
|
|
34
|
+
#metricNames: MetricNames = {
|
|
35
|
+
errors: "errors",
|
|
36
|
+
alerts: "alerts",
|
|
37
|
+
mutate: "Mutate",
|
|
38
|
+
validate: "Validate",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Creates a MetricsCollector instance with prefixed metrics.
|
|
43
|
+
* @param [prefix='pepr'] - The prefix for the metric names.
|
|
44
|
+
*/
|
|
45
|
+
constructor(prefix = "pepr") {
|
|
46
|
+
this.#registry = new Registry();
|
|
47
|
+
this.#prefix = prefix;
|
|
48
|
+
this.addCounter(this.#metricNames.errors, "Mutation/Validate errors encountered");
|
|
49
|
+
this.addCounter(this.#metricNames.alerts, "Mutation/Validate bad api token received");
|
|
50
|
+
this.addSummary(this.#metricNames.mutate, "Mutation operation summary");
|
|
51
|
+
this.addSummary(this.#metricNames.validate, "Validation operation summary");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
#getMetricName = (name: string) => `${this.#prefix}_${name}`;
|
|
55
|
+
|
|
56
|
+
#addMetric = <T extends Counter<string> | Summary<string>>(
|
|
57
|
+
collection: Map<string, T>,
|
|
58
|
+
MetricType: new (args: MetricArgs) => T,
|
|
59
|
+
name: string,
|
|
60
|
+
help: string,
|
|
61
|
+
) => {
|
|
62
|
+
if (collection.has(this.#getMetricName(name))) {
|
|
63
|
+
Log.debug(`Metric for ${name} already exists`, loggingPrefix);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const metric = new MetricType({
|
|
68
|
+
name: this.#getMetricName(name),
|
|
69
|
+
help,
|
|
70
|
+
registers: [this.#registry],
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
collection.set(this.#getMetricName(name), metric);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
addCounter = (name: string, help: string) => {
|
|
77
|
+
this.#addMetric(this.#counters, promClient.Counter, name, help);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
addSummary = (name: string, help: string) => {
|
|
81
|
+
this.#addMetric(this.#summaries, promClient.Summary, name, help);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
incCounter = (name: string) => {
|
|
85
|
+
this.#counters.get(this.#getMetricName(name))?.inc();
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Increments the error counter.
|
|
90
|
+
*/
|
|
91
|
+
error = () => this.incCounter(this.#metricNames.errors);
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Increments the alerts counter.
|
|
95
|
+
*/
|
|
96
|
+
alert = () => this.incCounter(this.#metricNames.alerts);
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Observes the duration since the provided start time and updates the summary.
|
|
100
|
+
* @param startTime - The start time.
|
|
101
|
+
* @param name - The metrics summary to increment.
|
|
102
|
+
*/
|
|
103
|
+
observeEnd = (startTime: number, name: string = this.#metricNames.mutate) => {
|
|
104
|
+
this.#summaries.get(this.#getMetricName(name))?.observe(performance.now() - startTime);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Fetches the current metrics from the registry.
|
|
109
|
+
* @returns The metrics.
|
|
110
|
+
*/
|
|
111
|
+
getMetrics = () => this.#registry.metrics();
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Returns the current timestamp from performance.now() method. Useful for start timing an operation.
|
|
115
|
+
* @returns The timestamp.
|
|
116
|
+
*/
|
|
117
|
+
static observeStart() {
|
|
118
|
+
return performance.now();
|
|
119
|
+
}
|
|
120
|
+
}
|