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,326 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// SPDX-FileCopyrightText: 2023-Present The Pepr Authors
|
|
3
|
+
|
|
4
|
+
import express, { NextFunction } from "express";
|
|
5
|
+
import fs from "fs";
|
|
6
|
+
import https from "https";
|
|
7
|
+
|
|
8
|
+
import { Capability } from "../capability";
|
|
9
|
+
import { MutateResponse, AdmissionRequest, ValidateResponse } from "../k8s";
|
|
10
|
+
import Log from "../logger";
|
|
11
|
+
import { MetricsCollector } from "../metrics";
|
|
12
|
+
import { ModuleConfig, isWatchMode } from "../module";
|
|
13
|
+
import { mutateProcessor } from "../mutate-processor";
|
|
14
|
+
import { validateProcessor } from "../validate-processor";
|
|
15
|
+
import { PeprControllerStore } from "./store";
|
|
16
|
+
import { ResponseItem } from "../types";
|
|
17
|
+
|
|
18
|
+
export class Controller {
|
|
19
|
+
// Track whether the server is running
|
|
20
|
+
#running = false;
|
|
21
|
+
|
|
22
|
+
// Metrics collector
|
|
23
|
+
#metricsCollector = new MetricsCollector("pepr");
|
|
24
|
+
|
|
25
|
+
// The token used to authenticate requests
|
|
26
|
+
#token = "";
|
|
27
|
+
|
|
28
|
+
// The express app instance
|
|
29
|
+
readonly #app = express();
|
|
30
|
+
|
|
31
|
+
// Initialized with the constructor
|
|
32
|
+
readonly #config: ModuleConfig;
|
|
33
|
+
readonly #capabilities: Capability[];
|
|
34
|
+
readonly #beforeHook?: (req: AdmissionRequest) => void;
|
|
35
|
+
readonly #afterHook?: (res: MutateResponse | ValidateResponse) => void;
|
|
36
|
+
|
|
37
|
+
constructor(
|
|
38
|
+
config: ModuleConfig,
|
|
39
|
+
capabilities: Capability[],
|
|
40
|
+
beforeHook?: (req: AdmissionRequest) => void,
|
|
41
|
+
afterHook?: (res: MutateResponse | ValidateResponse) => void,
|
|
42
|
+
onReady?: () => void,
|
|
43
|
+
) {
|
|
44
|
+
this.#config = config;
|
|
45
|
+
this.#capabilities = capabilities;
|
|
46
|
+
|
|
47
|
+
// Initialize the Pepr store for each capability
|
|
48
|
+
new PeprControllerStore(capabilities, `pepr-${config.uuid}-store`, () => {
|
|
49
|
+
this.#bindEndpoints();
|
|
50
|
+
onReady && onReady();
|
|
51
|
+
Log.info("✅ Controller startup complete");
|
|
52
|
+
// Initialize the schedule store for each capability
|
|
53
|
+
new PeprControllerStore(capabilities, `pepr-${config.uuid}-schedule`, () => {
|
|
54
|
+
Log.info("✅ Scheduling processed");
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Middleware for logging requests
|
|
59
|
+
this.#app.use(Controller.#logger);
|
|
60
|
+
|
|
61
|
+
// Middleware for parsing JSON, limit to 2mb vs 100K for K8s compatibility
|
|
62
|
+
this.#app.use(express.json({ limit: "2mb" }));
|
|
63
|
+
|
|
64
|
+
if (beforeHook) {
|
|
65
|
+
Log.info(`Using beforeHook: ${beforeHook}`);
|
|
66
|
+
this.#beforeHook = beforeHook;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (afterHook) {
|
|
70
|
+
Log.info(`Using afterHook: ${afterHook}`);
|
|
71
|
+
this.#afterHook = afterHook;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Start the webhook server */
|
|
76
|
+
startServer = (port: number) => {
|
|
77
|
+
if (this.#running) {
|
|
78
|
+
throw new Error("Cannot start Pepr module: Pepr module was not instantiated with deferStart=true");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Load SSL certificate and key
|
|
82
|
+
const options = {
|
|
83
|
+
key: fs.readFileSync(process.env.SSL_KEY_PATH || "/etc/certs/tls.key"),
|
|
84
|
+
cert: fs.readFileSync(process.env.SSL_CERT_PATH || "/etc/certs/tls.crt"),
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// Get the API token if not in watch mode
|
|
88
|
+
if (!isWatchMode()) {
|
|
89
|
+
// Get the API token from the environment variable or the mounted secret
|
|
90
|
+
this.#token = process.env.PEPR_API_TOKEN || fs.readFileSync("/app/api-token/value").toString().trim();
|
|
91
|
+
Log.info(`Using API token: ${this.#token}`);
|
|
92
|
+
|
|
93
|
+
if (!this.#token) {
|
|
94
|
+
throw new Error("API token not found");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Create HTTPS server
|
|
99
|
+
const server = https.createServer(options, this.#app).listen(port);
|
|
100
|
+
|
|
101
|
+
// Handle server listening event
|
|
102
|
+
server.on("listening", () => {
|
|
103
|
+
Log.info(`Server listening on port ${port}`);
|
|
104
|
+
// Track that the server is running
|
|
105
|
+
this.#running = true;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Handle EADDRINUSE errors
|
|
109
|
+
server.on("error", (e: { code: string }) => {
|
|
110
|
+
if (e.code === "EADDRINUSE") {
|
|
111
|
+
Log.warn(
|
|
112
|
+
`Address in use, retrying in 2 seconds. If this persists, ensure ${port} is not in use, e.g. "lsof -i :${port}"`,
|
|
113
|
+
);
|
|
114
|
+
setTimeout(() => {
|
|
115
|
+
server.close();
|
|
116
|
+
server.listen(port);
|
|
117
|
+
}, 2000);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Listen for the SIGTERM signal and gracefully close the server
|
|
122
|
+
process.on("SIGTERM", () => {
|
|
123
|
+
Log.info("Received SIGTERM, closing server");
|
|
124
|
+
server.close(() => {
|
|
125
|
+
Log.info("Server closed");
|
|
126
|
+
process.exit(0);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
#bindEndpoints = () => {
|
|
132
|
+
// Health check endpoint
|
|
133
|
+
this.#app.get("/healthz", Controller.#healthz);
|
|
134
|
+
|
|
135
|
+
// Metrics endpoint
|
|
136
|
+
this.#app.get("/metrics", this.#metrics);
|
|
137
|
+
|
|
138
|
+
if (isWatchMode()) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Require auth for webhook endpoints
|
|
143
|
+
this.#app.use(["/mutate/:token", "/validate/:token"], this.#validateToken);
|
|
144
|
+
|
|
145
|
+
// Mutate endpoint
|
|
146
|
+
this.#app.post("/mutate/:token", this.#admissionReq("Mutate"));
|
|
147
|
+
|
|
148
|
+
// Validate endpoint
|
|
149
|
+
this.#app.post("/validate/:token", this.#admissionReq("Validate"));
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Validate the token in the request path
|
|
154
|
+
*
|
|
155
|
+
* @param req The incoming request
|
|
156
|
+
* @param res The outgoing response
|
|
157
|
+
* @param next The next middleware function
|
|
158
|
+
* @returns
|
|
159
|
+
*/
|
|
160
|
+
#validateToken = (req: express.Request, res: express.Response, next: NextFunction) => {
|
|
161
|
+
// Validate the token
|
|
162
|
+
const { token } = req.params;
|
|
163
|
+
if (token !== this.#token) {
|
|
164
|
+
const err = `Unauthorized: invalid token '${token.replace(/[^\w]/g, "_")}'`;
|
|
165
|
+
Log.warn(err);
|
|
166
|
+
res.status(401).send(err);
|
|
167
|
+
this.#metricsCollector.alert();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Token is valid, continue
|
|
172
|
+
next();
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Metrics endpoint handler
|
|
177
|
+
*
|
|
178
|
+
* @param req the incoming request
|
|
179
|
+
* @param res the outgoing response
|
|
180
|
+
*/
|
|
181
|
+
#metrics = async (req: express.Request, res: express.Response) => {
|
|
182
|
+
try {
|
|
183
|
+
res.send(await this.#metricsCollector.getMetrics());
|
|
184
|
+
} catch (err) {
|
|
185
|
+
Log.error(err, `Error getting metrics`);
|
|
186
|
+
res.status(500).send("Internal Server Error");
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Admission request handler for both mutate and validate requests
|
|
192
|
+
*
|
|
193
|
+
* @param admissionKind the type of admission request
|
|
194
|
+
* @returns the request handler
|
|
195
|
+
*/
|
|
196
|
+
#admissionReq = (admissionKind: "Mutate" | "Validate") => {
|
|
197
|
+
// Create the admission request handler
|
|
198
|
+
return async (req: express.Request, res: express.Response) => {
|
|
199
|
+
// Start the metrics timer
|
|
200
|
+
const startTime = MetricsCollector.observeStart();
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
// Get the request from the body or create an empty request
|
|
204
|
+
const request: AdmissionRequest = req.body?.request || ({} as AdmissionRequest);
|
|
205
|
+
|
|
206
|
+
// Run the before hook if it exists
|
|
207
|
+
this.#beforeHook && this.#beforeHook(request || {});
|
|
208
|
+
|
|
209
|
+
// Setup identifiers for logging
|
|
210
|
+
const name = request?.name ? `/${request.name}` : "";
|
|
211
|
+
const namespace = request?.namespace || "";
|
|
212
|
+
const gvk = request?.kind || { group: "", version: "", kind: "" };
|
|
213
|
+
|
|
214
|
+
const reqMetadata = {
|
|
215
|
+
uid: request.uid,
|
|
216
|
+
namespace,
|
|
217
|
+
name,
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
Log.info({ ...reqMetadata, gvk, operation: request.operation, admissionKind }, "Incoming request");
|
|
221
|
+
Log.debug({ ...reqMetadata, request }, "Incoming request body");
|
|
222
|
+
|
|
223
|
+
// Process the request
|
|
224
|
+
let response: MutateResponse | ValidateResponse[];
|
|
225
|
+
|
|
226
|
+
// Call mutate or validate based on the admission kind
|
|
227
|
+
if (admissionKind === "Mutate") {
|
|
228
|
+
response = await mutateProcessor(this.#config, this.#capabilities, request, reqMetadata);
|
|
229
|
+
} else {
|
|
230
|
+
response = await validateProcessor(this.#capabilities, request, reqMetadata);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Run the after hook if it exists
|
|
234
|
+
const responseList: ValidateResponse[] | MutateResponse[] = Array.isArray(response) ? response : [response];
|
|
235
|
+
responseList.map(res => {
|
|
236
|
+
this.#afterHook && this.#afterHook(res);
|
|
237
|
+
// Log the response
|
|
238
|
+
Log.info({ ...reqMetadata, res }, "Check response");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
let kubeAdmissionResponse: ValidateResponse[] | MutateResponse | ResponseItem;
|
|
242
|
+
|
|
243
|
+
if (admissionKind === "Mutate") {
|
|
244
|
+
kubeAdmissionResponse = response;
|
|
245
|
+
Log.debug({ ...reqMetadata, response }, "Outgoing response");
|
|
246
|
+
res.send({
|
|
247
|
+
apiVersion: "admission.k8s.io/v1",
|
|
248
|
+
kind: "AdmissionReview",
|
|
249
|
+
response: kubeAdmissionResponse,
|
|
250
|
+
});
|
|
251
|
+
} else {
|
|
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
|
+
};
|
|
269
|
+
res.send({
|
|
270
|
+
apiVersion: "admission.k8s.io/v1",
|
|
271
|
+
kind: "AdmissionReview",
|
|
272
|
+
response: kubeAdmissionResponse,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
Log.debug({ ...reqMetadata, kubeAdmissionResponse }, "Outgoing response");
|
|
277
|
+
|
|
278
|
+
this.#metricsCollector.observeEnd(startTime, admissionKind);
|
|
279
|
+
} catch (err) {
|
|
280
|
+
Log.error(err, `Error processing ${admissionKind} request`);
|
|
281
|
+
res.status(500).send("Internal Server Error");
|
|
282
|
+
this.#metricsCollector.error();
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Middleware for logging requests
|
|
289
|
+
*
|
|
290
|
+
* @param req the incoming request
|
|
291
|
+
* @param res the outgoing response
|
|
292
|
+
* @param next the next middleware function
|
|
293
|
+
*/
|
|
294
|
+
static #logger(req: express.Request, res: express.Response, next: express.NextFunction) {
|
|
295
|
+
const startTime = Date.now();
|
|
296
|
+
|
|
297
|
+
res.on("finish", () => {
|
|
298
|
+
const elapsedTime = Date.now() - startTime;
|
|
299
|
+
const message = {
|
|
300
|
+
uid: req.body?.request?.uid,
|
|
301
|
+
method: req.method,
|
|
302
|
+
url: req.originalUrl,
|
|
303
|
+
status: res.statusCode,
|
|
304
|
+
duration: `${elapsedTime} ms`,
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
res.statusCode >= 300 ? Log.warn(message) : Log.info(message);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
next();
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Health check endpoint handler
|
|
314
|
+
*
|
|
315
|
+
* @param req the incoming request
|
|
316
|
+
* @param res the outgoing response
|
|
317
|
+
*/
|
|
318
|
+
static #healthz(req: express.Request, res: express.Response) {
|
|
319
|
+
try {
|
|
320
|
+
res.send("OK");
|
|
321
|
+
} catch (err) {
|
|
322
|
+
Log.error(err, `Error processing health check`);
|
|
323
|
+
res.status(500).send("Internal Server Error");
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// SPDX-FileCopyrightText: 2023-Present The Pepr Authors
|
|
3
|
+
|
|
4
|
+
import { Operation } from "fast-json-patch";
|
|
5
|
+
import { K8s } from "kubernetes-fluent-client";
|
|
6
|
+
import { startsWith } from "ramda";
|
|
7
|
+
|
|
8
|
+
import { Capability } from "../capability";
|
|
9
|
+
import { PeprStore } from "../k8s";
|
|
10
|
+
import Log from "../logger";
|
|
11
|
+
import { DataOp, DataSender, DataStore, Storage } from "../storage";
|
|
12
|
+
|
|
13
|
+
const namespace = "pepr-system";
|
|
14
|
+
export const debounceBackoff = 5000;
|
|
15
|
+
|
|
16
|
+
export class PeprControllerStore {
|
|
17
|
+
#name: string;
|
|
18
|
+
#stores: Record<string, Storage> = {};
|
|
19
|
+
#sendDebounce: NodeJS.Timeout | undefined;
|
|
20
|
+
#onReady?: () => void;
|
|
21
|
+
|
|
22
|
+
constructor(capabilities: Capability[], name: string, onReady?: () => void) {
|
|
23
|
+
this.#onReady = onReady;
|
|
24
|
+
|
|
25
|
+
// Setup Pepr State bindings
|
|
26
|
+
this.#name = name;
|
|
27
|
+
|
|
28
|
+
if (name.includes("schedule")) {
|
|
29
|
+
// Establish the store for each capability
|
|
30
|
+
for (const { name, registerScheduleStore, hasSchedule } of capabilities) {
|
|
31
|
+
// Guard Clause to exit early
|
|
32
|
+
if (hasSchedule !== true) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
// Register the scheduleStore with the capability
|
|
36
|
+
const { scheduleStore } = registerScheduleStore();
|
|
37
|
+
|
|
38
|
+
// Bind the store sender to the capability
|
|
39
|
+
scheduleStore.registerSender(this.#send(name));
|
|
40
|
+
|
|
41
|
+
// Store the storage instance
|
|
42
|
+
this.#stores[name] = scheduleStore;
|
|
43
|
+
}
|
|
44
|
+
} else {
|
|
45
|
+
// Establish the store for each capability
|
|
46
|
+
for (const { name, registerStore } of capabilities) {
|
|
47
|
+
// Register the store with the capability
|
|
48
|
+
const { store } = registerStore();
|
|
49
|
+
|
|
50
|
+
// Bind the store sender to the capability
|
|
51
|
+
store.registerSender(this.#send(name));
|
|
52
|
+
|
|
53
|
+
// Store the storage instance
|
|
54
|
+
this.#stores[name] = store;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Add a jitter to the Store creation to avoid collisions
|
|
59
|
+
setTimeout(
|
|
60
|
+
() =>
|
|
61
|
+
K8s(PeprStore)
|
|
62
|
+
.InNamespace(namespace)
|
|
63
|
+
.Get(this.#name)
|
|
64
|
+
// If the get succeeds, setup the watch
|
|
65
|
+
.then(this.#setupWatch)
|
|
66
|
+
// Otherwise, create the resource
|
|
67
|
+
.catch(this.#createStoreResource),
|
|
68
|
+
Math.random() * 3000,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
#setupWatch = () => {
|
|
73
|
+
const watcher = K8s(PeprStore, { name: this.#name, namespace }).Watch(this.#receive);
|
|
74
|
+
watcher.start().catch(e => Log.error(e, "Error starting Pepr store watch"));
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
#receive = (store: PeprStore) => {
|
|
78
|
+
Log.debug(store, "Pepr Store update");
|
|
79
|
+
|
|
80
|
+
// Wrap the update in a debounced function
|
|
81
|
+
const debounced = () => {
|
|
82
|
+
// Base64 decode the data
|
|
83
|
+
const data: DataStore = store.data || {};
|
|
84
|
+
|
|
85
|
+
// Loop over each stored capability
|
|
86
|
+
for (const name of Object.keys(this.#stores)) {
|
|
87
|
+
// Get the prefix offset for the keys
|
|
88
|
+
const offset = `${name}-`.length;
|
|
89
|
+
|
|
90
|
+
// Get any keys that match the capability name prefix
|
|
91
|
+
const filtered: DataStore = {};
|
|
92
|
+
|
|
93
|
+
// Loop over each key in the secret
|
|
94
|
+
for (const key of Object.keys(data)) {
|
|
95
|
+
// Match on the capability name as a prefix
|
|
96
|
+
if (startsWith(name, key)) {
|
|
97
|
+
// Strip the prefix and store the value
|
|
98
|
+
filtered[key.slice(offset)] = data[key];
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Send the data to the receiver callback
|
|
103
|
+
this.#stores[name].receive(filtered);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Call the onReady callback if this is the first time the secret has been read
|
|
107
|
+
if (this.#onReady) {
|
|
108
|
+
this.#onReady();
|
|
109
|
+
this.#onReady = undefined;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// Debounce the update to 1 second to avoid multiple rapid calls
|
|
114
|
+
clearTimeout(this.#sendDebounce);
|
|
115
|
+
this.#sendDebounce = setTimeout(debounced, this.#onReady ? 0 : debounceBackoff);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
#send = (capabilityName: string) => {
|
|
119
|
+
const sendCache: Record<string, Operation> = {};
|
|
120
|
+
|
|
121
|
+
// Load the sendCache with patch operations
|
|
122
|
+
const fillCache = (op: DataOp, key: string[], val?: string) => {
|
|
123
|
+
if (op === "add") {
|
|
124
|
+
const path = `/data/${capabilityName}-${key}`;
|
|
125
|
+
const value = val || "";
|
|
126
|
+
const cacheIdx = [op, path, value].join(":");
|
|
127
|
+
|
|
128
|
+
// Add the operation to the cache
|
|
129
|
+
sendCache[cacheIdx] = { op, path, value };
|
|
130
|
+
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (op === "remove") {
|
|
135
|
+
if (key.length < 1) {
|
|
136
|
+
throw new Error(`Key is required for REMOVE operation`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (const k of key) {
|
|
140
|
+
const path = `/data/${capabilityName}-${k}`;
|
|
141
|
+
const cacheIdx = [op, path].join(":");
|
|
142
|
+
|
|
143
|
+
// Add the operation to the cache
|
|
144
|
+
sendCache[cacheIdx] = { op, path };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// If we get here, the operation is not supported
|
|
151
|
+
throw new Error(`Unsupported operation: ${op}`);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// Send the cached updates to the cluster
|
|
155
|
+
const flushCache = async () => {
|
|
156
|
+
const indexes = Object.keys(sendCache);
|
|
157
|
+
const payload = Object.values(sendCache);
|
|
158
|
+
|
|
159
|
+
// Loop over each key in the cache and delete it to avoid collisions with other sender calls
|
|
160
|
+
for (const idx of indexes) {
|
|
161
|
+
delete sendCache[idx];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
// Send the patch to the cluster
|
|
166
|
+
await K8s(PeprStore, { namespace, name: this.#name }).Patch(payload);
|
|
167
|
+
} catch (err) {
|
|
168
|
+
Log.error(err, "Pepr store update failure");
|
|
169
|
+
|
|
170
|
+
if (err.status === 422) {
|
|
171
|
+
Object.keys(sendCache).forEach(key => delete sendCache[key]);
|
|
172
|
+
} else {
|
|
173
|
+
// On failure to update, re-add the operations to the cache to be retried
|
|
174
|
+
for (const idx of indexes) {
|
|
175
|
+
sendCache[idx] = payload[Number(idx)];
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
// Create a sender function for the capability to add/remove data from the store
|
|
182
|
+
const sender: DataSender = async (op: DataOp, key: string[], val?: string) => {
|
|
183
|
+
fillCache(op, key, val);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// Send any cached updates every debounceBackoff milliseconds
|
|
187
|
+
setInterval(() => {
|
|
188
|
+
if (Object.keys(sendCache).length > 0) {
|
|
189
|
+
Log.debug(sendCache, "Sending updates to Pepr store");
|
|
190
|
+
void flushCache();
|
|
191
|
+
}
|
|
192
|
+
}, debounceBackoff);
|
|
193
|
+
|
|
194
|
+
return sender;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
#createStoreResource = async (e: unknown) => {
|
|
198
|
+
Log.info(`Pepr store not found, creating...`);
|
|
199
|
+
Log.debug(e);
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
await K8s(PeprStore).Apply({
|
|
203
|
+
metadata: {
|
|
204
|
+
name: this.#name,
|
|
205
|
+
namespace,
|
|
206
|
+
},
|
|
207
|
+
data: {
|
|
208
|
+
// JSON Patch will die if the data is empty, so we need to add a placeholder
|
|
209
|
+
__pepr_do_not_delete__: "k-thx-bye",
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// Now that the resource exists, setup the watch
|
|
214
|
+
this.#setupWatch();
|
|
215
|
+
} catch (err) {
|
|
216
|
+
Log.error(err, "Failed to create Pepr store");
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// SPDX-FileCopyrightText: 2023-Present The Pepr Authors
|
|
3
|
+
|
|
4
|
+
export const Errors = {
|
|
5
|
+
audit: "audit",
|
|
6
|
+
ignore: "ignore",
|
|
7
|
+
reject: "reject",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const ErrorList = Object.values(Errors);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Validate the error or throw an error
|
|
14
|
+
* @param error
|
|
15
|
+
*/
|
|
16
|
+
export function ValidateError(error = "") {
|
|
17
|
+
if (!ErrorList.includes(error)) {
|
|
18
|
+
throw new Error(`Invalid error: ${error}. Must be one of: ${ErrorList.join(", ")}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// SPDX-FileCopyrightText: 2023-Present The Pepr Authors
|
|
3
|
+
|
|
4
|
+
import { AdmissionRequest, Operation } from "./k8s";
|
|
5
|
+
import logger from "./logger";
|
|
6
|
+
import { Binding, Event } from "./types";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* shouldSkipRequest determines if a request should be skipped based on the binding filters.
|
|
10
|
+
*
|
|
11
|
+
* @param binding the action binding
|
|
12
|
+
* @param req the incoming request
|
|
13
|
+
* @returns
|
|
14
|
+
*/
|
|
15
|
+
export function shouldSkipRequest(binding: Binding, req: AdmissionRequest, capabilityNamespaces: string[]) {
|
|
16
|
+
const { group, kind, version } = binding.kind || {};
|
|
17
|
+
const { namespaces, labels, annotations, name } = binding.filters || {};
|
|
18
|
+
const operation = req.operation.toUpperCase();
|
|
19
|
+
const uid = req.uid;
|
|
20
|
+
// Use the old object if the request is a DELETE operation
|
|
21
|
+
const srcObject = operation === Operation.DELETE ? req.oldObject : req.object;
|
|
22
|
+
const { metadata } = srcObject || {};
|
|
23
|
+
const combinedNamespaces = [...namespaces, ...capabilityNamespaces];
|
|
24
|
+
|
|
25
|
+
// Test for matching operation
|
|
26
|
+
if (!binding.event.includes(operation) && !binding.event.includes(Event.Any)) {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Test name first, since it's the most specific
|
|
31
|
+
if (name && name !== req.name) {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Test for matching kinds
|
|
36
|
+
if (kind !== req.kind.kind) {
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Test for matching groups
|
|
41
|
+
if (group && group !== req.kind.group) {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Test for matching versions
|
|
46
|
+
if (version && version !== req.kind.version) {
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Test for matching namespaces
|
|
51
|
+
if (
|
|
52
|
+
(combinedNamespaces.length && !combinedNamespaces.includes(req.namespace || "")) ||
|
|
53
|
+
(!namespaces.includes(req.namespace || "") && capabilityNamespaces.length !== 0 && namespaces.length !== 0)
|
|
54
|
+
) {
|
|
55
|
+
let type = "";
|
|
56
|
+
let label = "";
|
|
57
|
+
|
|
58
|
+
if (binding.isMutate) {
|
|
59
|
+
type = "Mutate";
|
|
60
|
+
label = binding.mutateCallback!.name;
|
|
61
|
+
} else if (binding.isValidate) {
|
|
62
|
+
type = "Validate";
|
|
63
|
+
label = binding.validateCallback!.name;
|
|
64
|
+
} else if (binding.isWatch) {
|
|
65
|
+
type = "Watch";
|
|
66
|
+
label = binding.watchCallback!.name;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
logger.debug({ uid }, `${type} binding (${label}) does not match request namespace "${req.namespace}"`);
|
|
70
|
+
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Test for matching labels
|
|
75
|
+
for (const [key, value] of Object.entries(labels)) {
|
|
76
|
+
const testKey = metadata?.labels?.[key];
|
|
77
|
+
|
|
78
|
+
// First check if the label exists
|
|
79
|
+
if (!testKey) {
|
|
80
|
+
logger.debug({ uid }, `Label ${key} does not exist`);
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Then check if the value matches, if specified
|
|
85
|
+
if (value && testKey !== value) {
|
|
86
|
+
logger.debug({ uid }, `${testKey} does not match ${value}`);
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Test for matching annotations
|
|
92
|
+
for (const [key, value] of Object.entries(annotations)) {
|
|
93
|
+
const testKey = metadata?.annotations?.[key];
|
|
94
|
+
|
|
95
|
+
// First check if the annotation exists
|
|
96
|
+
if (!testKey) {
|
|
97
|
+
logger.debug({ uid }, `Annotation ${key} does not exist`);
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Then check if the value matches, if specified
|
|
102
|
+
if (value && testKey !== value) {
|
|
103
|
+
logger.debug({ uid }, `${testKey} does not match ${value}`);
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// No failed filters, so we should not skip this request
|
|
109
|
+
return false;
|
|
110
|
+
}
|