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,136 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// SPDX-FileCopyrightText: 2023-Present The Pepr Authors
|
|
3
|
+
import { clone } from "ramda";
|
|
4
|
+
import { Capability } from "./capability";
|
|
5
|
+
import { Controller } from "./controller";
|
|
6
|
+
import { ValidateError } from "./errors";
|
|
7
|
+
import { AdmissionRequest, MutateResponse, ValidateResponse, WebhookIgnore } from "./k8s";
|
|
8
|
+
import { CapabilityExport } from "./types";
|
|
9
|
+
import { setupWatch } from "./watch-processor";
|
|
10
|
+
import { Log } from "../lib";
|
|
11
|
+
|
|
12
|
+
/** Custom Labels Type for package.json */
|
|
13
|
+
export interface CustomLabels {
|
|
14
|
+
namespace?: Record<string, string>;
|
|
15
|
+
}
|
|
16
|
+
/** Global configuration for the Pepr runtime. */
|
|
17
|
+
export type ModuleConfig = {
|
|
18
|
+
/** The Pepr version this module uses */
|
|
19
|
+
peprVersion?: string;
|
|
20
|
+
/** The user-defined version of the module */
|
|
21
|
+
appVersion?: string;
|
|
22
|
+
/** A unique identifier for this Pepr module. This is automatically generated by Pepr. */
|
|
23
|
+
uuid: string;
|
|
24
|
+
/** A description of the Pepr module and what it does. */
|
|
25
|
+
description?: string;
|
|
26
|
+
/** The webhookTimeout */
|
|
27
|
+
webhookTimeout?: number;
|
|
28
|
+
/** Reject K8s resource AdmissionRequests on error. */
|
|
29
|
+
onError?: string;
|
|
30
|
+
/** Configure global exclusions that will never be processed by Pepr. */
|
|
31
|
+
alwaysIgnore: WebhookIgnore;
|
|
32
|
+
/** Define the log level for the in-cluster controllers */
|
|
33
|
+
logLevel?: string;
|
|
34
|
+
/** Propagate env variables to in-cluster controllers */
|
|
35
|
+
env?: Record<string, string>;
|
|
36
|
+
/** Custom Labels for Kubernetes Objects */
|
|
37
|
+
customLabels?: CustomLabels;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type PackageJSON = {
|
|
41
|
+
description: string;
|
|
42
|
+
pepr: ModuleConfig;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type PeprModuleOptions = {
|
|
46
|
+
deferStart?: boolean;
|
|
47
|
+
|
|
48
|
+
/** A user-defined callback to pre-process or intercept a Pepr request from K8s immediately before it is processed */
|
|
49
|
+
beforeHook?: (req: AdmissionRequest) => void;
|
|
50
|
+
|
|
51
|
+
/** A user-defined callback to post-process or intercept a Pepr response just before it is returned to K8s */
|
|
52
|
+
afterHook?: (res: MutateResponse | ValidateResponse) => void;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Track if this is a watch mode controller
|
|
56
|
+
export const isWatchMode = () => process.env.PEPR_WATCH_MODE === "true";
|
|
57
|
+
|
|
58
|
+
// Track if Pepr is running in build mode
|
|
59
|
+
export const isBuildMode = () => process.env.PEPR_MODE === "build";
|
|
60
|
+
|
|
61
|
+
export const isDevMode = () => process.env.PEPR_MODE === "dev";
|
|
62
|
+
|
|
63
|
+
export class PeprModule {
|
|
64
|
+
#controller!: Controller;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Create a new Pepr runtime
|
|
68
|
+
*
|
|
69
|
+
* @param config The configuration for the Pepr runtime
|
|
70
|
+
* @param capabilities The capabilities to be loaded into the Pepr runtime
|
|
71
|
+
* @param opts Options for the Pepr runtime
|
|
72
|
+
*/
|
|
73
|
+
constructor({ description, pepr }: PackageJSON, capabilities: Capability[] = [], opts: PeprModuleOptions = {}) {
|
|
74
|
+
const config: ModuleConfig = clone(pepr);
|
|
75
|
+
config.description = description;
|
|
76
|
+
|
|
77
|
+
// Need to validate at runtime since TS gets sad about parsing the package.json
|
|
78
|
+
ValidateError(config.onError);
|
|
79
|
+
|
|
80
|
+
// Handle build mode
|
|
81
|
+
if (isBuildMode()) {
|
|
82
|
+
// Fail if process.send is not defined
|
|
83
|
+
if (!process.send) {
|
|
84
|
+
throw new Error("process.send is not defined");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const exportedCapabilities: CapabilityExport[] = [];
|
|
88
|
+
|
|
89
|
+
// Send capability map to parent process
|
|
90
|
+
for (const capability of capabilities) {
|
|
91
|
+
// Convert the capability to a capability config
|
|
92
|
+
exportedCapabilities.push({
|
|
93
|
+
name: capability.name,
|
|
94
|
+
description: capability.description,
|
|
95
|
+
namespaces: capability.namespaces,
|
|
96
|
+
bindings: capability.bindings,
|
|
97
|
+
hasSchedule: capability.hasSchedule,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Send the capabilities back to the parent process
|
|
102
|
+
process.send(exportedCapabilities);
|
|
103
|
+
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
this.#controller = new Controller(config, capabilities, opts.beforeHook, opts.afterHook, () => {
|
|
108
|
+
// Wait for the controller to be ready before setting up watches
|
|
109
|
+
if (isWatchMode() || isDevMode()) {
|
|
110
|
+
try {
|
|
111
|
+
setupWatch(capabilities);
|
|
112
|
+
} catch (e) {
|
|
113
|
+
Log.error(e, "Error setting up watch");
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Stop processing if deferStart is set to true
|
|
120
|
+
if (opts.deferStart) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
this.start();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Start the Pepr runtime manually.
|
|
129
|
+
* Normally this is called automatically when the Pepr module is instantiated, but can be called manually if `deferStart` is set to `true` in the constructor.
|
|
130
|
+
*
|
|
131
|
+
* @param port
|
|
132
|
+
*/
|
|
133
|
+
start = (port = 3000) => {
|
|
134
|
+
this.#controller.startServer(port);
|
|
135
|
+
};
|
|
136
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// SPDX-FileCopyrightText: 2023-Present The Pepr Authors
|
|
3
|
+
|
|
4
|
+
import jsonPatch from "fast-json-patch";
|
|
5
|
+
import { kind } from "kubernetes-fluent-client";
|
|
6
|
+
|
|
7
|
+
import { Capability } from "./capability";
|
|
8
|
+
import { Errors } from "./errors";
|
|
9
|
+
import { shouldSkipRequest } from "./filter";
|
|
10
|
+
import { MutateResponse, AdmissionRequest } from "./k8s";
|
|
11
|
+
import Log from "./logger";
|
|
12
|
+
import { ModuleConfig } from "./module";
|
|
13
|
+
import { PeprMutateRequest } from "./mutate-request";
|
|
14
|
+
import { base64Encode, convertFromBase64Map, convertToBase64Map } from "./utils";
|
|
15
|
+
|
|
16
|
+
export async function mutateProcessor(
|
|
17
|
+
config: ModuleConfig,
|
|
18
|
+
capabilities: Capability[],
|
|
19
|
+
req: AdmissionRequest,
|
|
20
|
+
reqMetadata: Record<string, string>,
|
|
21
|
+
): Promise<MutateResponse> {
|
|
22
|
+
const wrapped = new PeprMutateRequest(req);
|
|
23
|
+
const response: MutateResponse = {
|
|
24
|
+
uid: req.uid,
|
|
25
|
+
warnings: [],
|
|
26
|
+
allowed: false,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Track whether any capability matched the request
|
|
30
|
+
let matchedAction = false;
|
|
31
|
+
|
|
32
|
+
// Track data fields that should be skipped during decoding
|
|
33
|
+
let skipDecode: string[] = [];
|
|
34
|
+
|
|
35
|
+
// If the resource is a secret, decode the data
|
|
36
|
+
const isSecret = req.kind.version == "v1" && req.kind.kind == "Secret";
|
|
37
|
+
if (isSecret) {
|
|
38
|
+
skipDecode = convertFromBase64Map(wrapped.Raw as unknown as kind.Secret);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
Log.info(reqMetadata, `Processing request`);
|
|
42
|
+
|
|
43
|
+
for (const { name, bindings, namespaces } of capabilities) {
|
|
44
|
+
const actionMetadata = { ...reqMetadata, name };
|
|
45
|
+
|
|
46
|
+
for (const action of bindings) {
|
|
47
|
+
// Skip this action if it's not a mutate action
|
|
48
|
+
if (!action.mutateCallback) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Continue to the next action without doing anything if this one should be skipped
|
|
53
|
+
if (shouldSkipRequest(action, req, namespaces)) {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const label = action.mutateCallback.name;
|
|
58
|
+
Log.info(actionMetadata, `Processing mutation action (${label})`);
|
|
59
|
+
|
|
60
|
+
matchedAction = true;
|
|
61
|
+
|
|
62
|
+
// Add annotations to the request to indicate that the capability started processing
|
|
63
|
+
// this will allow tracking of failed mutations that were permitted to continue
|
|
64
|
+
const updateStatus = (status: string) => {
|
|
65
|
+
// Only update the status if the request is a CREATE or UPDATE (we don't use CONNECT)
|
|
66
|
+
if (req.operation == "DELETE") {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const identifier = `${config.uuid}.pepr.dev/${name}`;
|
|
71
|
+
wrapped.Raw.metadata = wrapped.Raw.metadata || {};
|
|
72
|
+
wrapped.Raw.metadata.annotations = wrapped.Raw.metadata.annotations || {};
|
|
73
|
+
wrapped.Raw.metadata.annotations[identifier] = status;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
updateStatus("started");
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
// Run the action
|
|
80
|
+
await action.mutateCallback(wrapped);
|
|
81
|
+
|
|
82
|
+
Log.info(actionMetadata, `Mutation action succeeded (${label})`);
|
|
83
|
+
|
|
84
|
+
// Add annotations to the request to indicate that the capability succeeded
|
|
85
|
+
updateStatus("succeeded");
|
|
86
|
+
} catch (e) {
|
|
87
|
+
updateStatus("warning");
|
|
88
|
+
response.warnings = response.warnings || [];
|
|
89
|
+
|
|
90
|
+
let errorMessage = "";
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
if (e.message && e.message !== "[object Object]") {
|
|
94
|
+
errorMessage = e.message;
|
|
95
|
+
} else {
|
|
96
|
+
throw new Error("An error occurred in the mutate action.");
|
|
97
|
+
}
|
|
98
|
+
} catch (e) {
|
|
99
|
+
errorMessage = "An error occurred with the mutate action.";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
Log.error(actionMetadata, `Action failed: ${errorMessage}`);
|
|
103
|
+
response.warnings.push(`Action failed: ${errorMessage}`);
|
|
104
|
+
|
|
105
|
+
switch (config.onError) {
|
|
106
|
+
case Errors.reject:
|
|
107
|
+
Log.error(actionMetadata, `Action failed: ${errorMessage}`);
|
|
108
|
+
response.result = "Pepr module configured to reject on error";
|
|
109
|
+
return response;
|
|
110
|
+
|
|
111
|
+
case Errors.audit:
|
|
112
|
+
response.auditAnnotations = response.auditAnnotations || {};
|
|
113
|
+
response.auditAnnotations[Date.now()] = `Action failed: ${errorMessage}`;
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// If we've made it this far, the request is allowed
|
|
121
|
+
response.allowed = true;
|
|
122
|
+
|
|
123
|
+
// If no capability matched the request, exit early
|
|
124
|
+
if (!matchedAction) {
|
|
125
|
+
Log.info(reqMetadata, `No matching actions found`);
|
|
126
|
+
return response;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// delete operations can't be mutate, just return before the transformation
|
|
130
|
+
if (req.operation == "DELETE") {
|
|
131
|
+
return response;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const transformed = wrapped.Raw;
|
|
135
|
+
|
|
136
|
+
// Post-process the Secret requests to convert it back to the original format
|
|
137
|
+
if (isSecret) {
|
|
138
|
+
convertToBase64Map(transformed as unknown as kind.Secret, skipDecode);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Compare the original request to the modified request to get the patches
|
|
142
|
+
const patches = jsonPatch.compare(req.object, transformed);
|
|
143
|
+
|
|
144
|
+
// Only add the patch if there are patches to apply
|
|
145
|
+
if (patches.length > 0) {
|
|
146
|
+
response.patchType = "JSONPatch";
|
|
147
|
+
// Webhook must be base64-encoded
|
|
148
|
+
// https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#response
|
|
149
|
+
response.patch = base64Encode(JSON.stringify(patches));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Remove the warnings array if it's empty
|
|
153
|
+
if (response.warnings && response.warnings.length < 1) {
|
|
154
|
+
delete response.warnings;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
Log.debug({ ...reqMetadata, patches }, `Patches generated`);
|
|
158
|
+
|
|
159
|
+
return response;
|
|
160
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// SPDX-FileCopyrightText: 2023-Present The Pepr Authors
|
|
3
|
+
|
|
4
|
+
import { KubernetesObject } from "kubernetes-fluent-client";
|
|
5
|
+
import { clone, mergeDeepRight } from "ramda";
|
|
6
|
+
|
|
7
|
+
import { AdmissionRequest, Operation } from "./k8s";
|
|
8
|
+
import { DeepPartial } from "./types";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The RequestWrapper class provides methods to modify Kubernetes objects in the context
|
|
12
|
+
* of a mutating webhook request.
|
|
13
|
+
*/
|
|
14
|
+
export class PeprMutateRequest<T extends KubernetesObject> {
|
|
15
|
+
Raw: T;
|
|
16
|
+
|
|
17
|
+
#input: AdmissionRequest<T>;
|
|
18
|
+
|
|
19
|
+
get PermitSideEffects() {
|
|
20
|
+
return !this.#input.dryRun;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Indicates whether the request is a dry run.
|
|
25
|
+
* @returns true if the request is a dry run, false otherwise.
|
|
26
|
+
*/
|
|
27
|
+
get IsDryRun() {
|
|
28
|
+
return this.#input.dryRun;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Provides access to the old resource in the request if available.
|
|
33
|
+
* @returns The old Kubernetes resource object or null if not available.
|
|
34
|
+
*/
|
|
35
|
+
get OldResource() {
|
|
36
|
+
return this.#input.oldObject;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Provides access to the request object.
|
|
41
|
+
* @returns The request object containing the Kubernetes resource.
|
|
42
|
+
*/
|
|
43
|
+
get Request() {
|
|
44
|
+
return this.#input;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Creates a new instance of the action class.
|
|
49
|
+
* @param input - The request object containing the Kubernetes resource to modify.
|
|
50
|
+
*/
|
|
51
|
+
constructor(input: AdmissionRequest<T>) {
|
|
52
|
+
this.#input = input;
|
|
53
|
+
|
|
54
|
+
// If this is a DELETE operation, use the oldObject instead
|
|
55
|
+
if (input.operation.toUpperCase() === Operation.DELETE) {
|
|
56
|
+
this.Raw = clone(input.oldObject as T);
|
|
57
|
+
} else {
|
|
58
|
+
// Otherwise, use the incoming object
|
|
59
|
+
this.Raw = clone(input.object);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!this.Raw) {
|
|
63
|
+
throw new Error("unable to load the request object into PeprRequest.RawP");
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Deep merges the provided object with the current resource.
|
|
69
|
+
*
|
|
70
|
+
* @param obj - The object to merge with the current resource.
|
|
71
|
+
*/
|
|
72
|
+
Merge = (obj: DeepPartial<T>) => {
|
|
73
|
+
this.Raw = mergeDeepRight(this.Raw, obj) as unknown as T;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Updates a label on the Kubernetes resource.
|
|
78
|
+
* @param key - The key of the label to update.
|
|
79
|
+
* @param value - The value of the label.
|
|
80
|
+
* @returns The current action instance for method chaining.
|
|
81
|
+
*/
|
|
82
|
+
SetLabel = (key: string, value: string) => {
|
|
83
|
+
const ref = this.Raw;
|
|
84
|
+
|
|
85
|
+
ref.metadata = ref.metadata ?? {};
|
|
86
|
+
ref.metadata.labels = ref.metadata.labels ?? {};
|
|
87
|
+
ref.metadata.labels[key] = value;
|
|
88
|
+
|
|
89
|
+
return this;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Updates an annotation on the Kubernetes resource.
|
|
94
|
+
* @param key - The key of the annotation to update.
|
|
95
|
+
* @param value - The value of the annotation.
|
|
96
|
+
* @returns The current action instance for method chaining.
|
|
97
|
+
*/
|
|
98
|
+
SetAnnotation = (key: string, value: string) => {
|
|
99
|
+
const ref = this.Raw;
|
|
100
|
+
|
|
101
|
+
ref.metadata = ref.metadata ?? {};
|
|
102
|
+
ref.metadata.annotations = ref.metadata.annotations ?? {};
|
|
103
|
+
ref.metadata.annotations[key] = value;
|
|
104
|
+
|
|
105
|
+
return this;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Removes a label from the Kubernetes resource.
|
|
110
|
+
* @param key - The key of the label to remove.
|
|
111
|
+
* @returns The current Action instance for method chaining.
|
|
112
|
+
*/
|
|
113
|
+
RemoveLabel = (key: string) => {
|
|
114
|
+
if (this.Raw.metadata?.labels?.[key]) {
|
|
115
|
+
delete this.Raw.metadata.labels[key];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return this;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Removes an annotation from the Kubernetes resource.
|
|
123
|
+
* @param key - The key of the annotation to remove.
|
|
124
|
+
* @returns The current Action instance for method chaining.
|
|
125
|
+
*/
|
|
126
|
+
RemoveAnnotation = (key: string) => {
|
|
127
|
+
if (this.Raw.metadata?.annotations?.[key]) {
|
|
128
|
+
delete this.Raw.metadata.annotations[key];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return this;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Check if a label exists on the Kubernetes resource.
|
|
136
|
+
*
|
|
137
|
+
* @param key the label key to check
|
|
138
|
+
* @returns
|
|
139
|
+
*/
|
|
140
|
+
HasLabel = (key: string) => {
|
|
141
|
+
return this.Raw.metadata?.labels?.[key] !== undefined;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Check if an annotation exists on the Kubernetes resource.
|
|
146
|
+
*
|
|
147
|
+
* @param key the annotation key to check
|
|
148
|
+
* @returns
|
|
149
|
+
*/
|
|
150
|
+
HasAnnotation = (key: string) => {
|
|
151
|
+
return this.Raw.metadata?.annotations?.[key] !== undefined;
|
|
152
|
+
};
|
|
153
|
+
}
|
package/src/lib/queue.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// SPDX-FileCopyrightText: 2023-Present The Pepr Authors
|
|
3
|
+
import { KubernetesObject } from "@kubernetes/client-node";
|
|
4
|
+
import { WatchPhase } from "kubernetes-fluent-client/dist/fluent/types";
|
|
5
|
+
import Log from "./logger";
|
|
6
|
+
|
|
7
|
+
type QueueItem<K extends KubernetesObject> = {
|
|
8
|
+
item: K;
|
|
9
|
+
type: WatchPhase;
|
|
10
|
+
resolve: (value: void | PromiseLike<void>) => void;
|
|
11
|
+
reject: (reason?: string) => void;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Queue is a FIFO queue for reconciling
|
|
16
|
+
*/
|
|
17
|
+
export class Queue<K extends KubernetesObject> {
|
|
18
|
+
#queue: QueueItem<K>[] = [];
|
|
19
|
+
#pendingPromise = false;
|
|
20
|
+
#reconcile?: (obj: KubernetesObject, type: WatchPhase) => Promise<void>;
|
|
21
|
+
|
|
22
|
+
constructor() {
|
|
23
|
+
this.#reconcile = async () => await new Promise(resolve => resolve());
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
setReconcile(reconcile: (obj: KubernetesObject, type: WatchPhase) => Promise<void>) {
|
|
27
|
+
this.#reconcile = reconcile;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Enqueue adds an item to the queue and returns a promise that resolves when the item is
|
|
32
|
+
* reconciled.
|
|
33
|
+
*
|
|
34
|
+
* @param item The object to reconcile
|
|
35
|
+
* @returns A promise that resolves when the object is reconciled
|
|
36
|
+
*/
|
|
37
|
+
enqueue(item: K, type: WatchPhase) {
|
|
38
|
+
Log.debug(`Enqueueing ${item.metadata!.namespace}/${item.metadata!.name}`);
|
|
39
|
+
return new Promise<void>((resolve, reject) => {
|
|
40
|
+
this.#queue.push({ item, type, resolve, reject });
|
|
41
|
+
return this.#dequeue();
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Dequeue reconciles the next item in the queue
|
|
47
|
+
*
|
|
48
|
+
* @returns A promise that resolves when the webapp is reconciled
|
|
49
|
+
*/
|
|
50
|
+
async #dequeue() {
|
|
51
|
+
// If there is a pending promise, do nothing
|
|
52
|
+
if (this.#pendingPromise) {
|
|
53
|
+
Log.debug("Pending promise, not dequeuing");
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Take the next element from the queue
|
|
58
|
+
const element = this.#queue.shift();
|
|
59
|
+
|
|
60
|
+
// If there is no element, do nothing
|
|
61
|
+
if (!element) {
|
|
62
|
+
Log.debug("No element, not dequeuing");
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
// Set the pending promise flag to avoid concurrent reconciliations
|
|
68
|
+
this.#pendingPromise = true;
|
|
69
|
+
|
|
70
|
+
// Reconcile the element
|
|
71
|
+
if (this.#reconcile) {
|
|
72
|
+
Log.debug(`Reconciling ${element.item.metadata!.name}`);
|
|
73
|
+
await this.#reconcile(element.item, element.type);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
element.resolve();
|
|
77
|
+
} catch (e) {
|
|
78
|
+
Log.debug(`Error reconciling ${element.item.metadata!.name}`, { error: e });
|
|
79
|
+
element.reject(e);
|
|
80
|
+
} finally {
|
|
81
|
+
// Reset the pending promise flag
|
|
82
|
+
Log.debug("Resetting pending promise and dequeuing");
|
|
83
|
+
this.#pendingPromise = false;
|
|
84
|
+
|
|
85
|
+
// After the element is reconciled, dequeue the next element
|
|
86
|
+
await this.#dequeue();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// SPDX-FileCopyrightText: 2023-Present The Pepr Authors
|
|
3
|
+
|
|
4
|
+
import { PeprStore } from "./storage";
|
|
5
|
+
|
|
6
|
+
type Unit = "seconds" | "second" | "minute" | "minutes" | "hours" | "hour";
|
|
7
|
+
|
|
8
|
+
export interface Schedule {
|
|
9
|
+
/**
|
|
10
|
+
* * The name of the store
|
|
11
|
+
*/
|
|
12
|
+
name: string;
|
|
13
|
+
/**
|
|
14
|
+
* The value associated with a unit of time
|
|
15
|
+
*/
|
|
16
|
+
every: number;
|
|
17
|
+
/**
|
|
18
|
+
* The unit of time
|
|
19
|
+
*/
|
|
20
|
+
unit: Unit;
|
|
21
|
+
/**
|
|
22
|
+
* The code to run
|
|
23
|
+
*/
|
|
24
|
+
run: () => void;
|
|
25
|
+
/**
|
|
26
|
+
* The start time of the schedule
|
|
27
|
+
*/
|
|
28
|
+
startTime?: Date | undefined;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The number of times the schedule has run
|
|
32
|
+
*/
|
|
33
|
+
completions?: number | undefined;
|
|
34
|
+
/**
|
|
35
|
+
* Tje intervalID to clear the interval
|
|
36
|
+
*/
|
|
37
|
+
intervalID?: NodeJS.Timeout;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class OnSchedule implements Schedule {
|
|
41
|
+
intervalId: NodeJS.Timeout | null = null;
|
|
42
|
+
store: PeprStore | undefined;
|
|
43
|
+
name!: string;
|
|
44
|
+
completions?: number | undefined;
|
|
45
|
+
every: number;
|
|
46
|
+
unit: Unit;
|
|
47
|
+
run!: () => void;
|
|
48
|
+
startTime?: Date | undefined;
|
|
49
|
+
duration: number | undefined;
|
|
50
|
+
lastTimestamp: Date | undefined;
|
|
51
|
+
|
|
52
|
+
constructor(schedule: Schedule) {
|
|
53
|
+
this.name = schedule.name;
|
|
54
|
+
this.run = schedule.run;
|
|
55
|
+
this.every = schedule.every;
|
|
56
|
+
this.unit = schedule.unit;
|
|
57
|
+
this.startTime = schedule?.startTime;
|
|
58
|
+
this.completions = schedule?.completions;
|
|
59
|
+
}
|
|
60
|
+
setStore(store: PeprStore) {
|
|
61
|
+
this.store = store;
|
|
62
|
+
this.startInterval();
|
|
63
|
+
}
|
|
64
|
+
startInterval() {
|
|
65
|
+
this.checkStore();
|
|
66
|
+
this.getDuration();
|
|
67
|
+
this.setupInterval();
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Checks the store for this schedule and sets the values if it exists
|
|
71
|
+
* @returns
|
|
72
|
+
*/
|
|
73
|
+
checkStore() {
|
|
74
|
+
const result = this.store && this.store.getItem(this.name);
|
|
75
|
+
if (result) {
|
|
76
|
+
const storedSchedule = JSON.parse(result);
|
|
77
|
+
this.completions = storedSchedule?.completions;
|
|
78
|
+
this.startTime = storedSchedule?.startTime;
|
|
79
|
+
this.lastTimestamp = storedSchedule?.lastTimestamp;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Saves the schedule to the store
|
|
85
|
+
* @returns
|
|
86
|
+
*/
|
|
87
|
+
saveToStore() {
|
|
88
|
+
const schedule = {
|
|
89
|
+
completions: this.completions,
|
|
90
|
+
startTime: this.startTime,
|
|
91
|
+
lastTimestamp: new Date(),
|
|
92
|
+
name: this.name,
|
|
93
|
+
};
|
|
94
|
+
this.store && this.store.setItem(this.name, JSON.stringify(schedule));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Gets the durations in milliseconds
|
|
99
|
+
*/
|
|
100
|
+
getDuration() {
|
|
101
|
+
switch (this.unit) {
|
|
102
|
+
case "seconds":
|
|
103
|
+
if (this.every < 10) throw new Error("10 Seconds in the smallest interval allowed");
|
|
104
|
+
this.duration = 1000 * this.every;
|
|
105
|
+
break;
|
|
106
|
+
case "minutes":
|
|
107
|
+
case "minute":
|
|
108
|
+
this.duration = 1000 * 60 * this.every;
|
|
109
|
+
break;
|
|
110
|
+
case "hours":
|
|
111
|
+
case "hour":
|
|
112
|
+
this.duration = 1000 * 60 * 60 * this.every;
|
|
113
|
+
break;
|
|
114
|
+
default:
|
|
115
|
+
throw new Error("Invalid time unit");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Sets up the interval
|
|
121
|
+
*/
|
|
122
|
+
setupInterval() {
|
|
123
|
+
const now = new Date();
|
|
124
|
+
let delay: number | undefined;
|
|
125
|
+
|
|
126
|
+
if (this.lastTimestamp && this.startTime) {
|
|
127
|
+
this.startTime = undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (this.startTime) {
|
|
131
|
+
delay = this.startTime.getTime() - now.getTime();
|
|
132
|
+
} else if (this.lastTimestamp && this.duration) {
|
|
133
|
+
const lastTimestamp = new Date(this.lastTimestamp);
|
|
134
|
+
delay = this.duration - (now.getTime() - lastTimestamp.getTime());
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (delay === undefined || delay <= 0) {
|
|
138
|
+
this.start();
|
|
139
|
+
} else {
|
|
140
|
+
setTimeout(() => {
|
|
141
|
+
this.start();
|
|
142
|
+
}, delay);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Starts the interval
|
|
148
|
+
*/
|
|
149
|
+
start() {
|
|
150
|
+
this.intervalId = setInterval(() => {
|
|
151
|
+
if (this.completions === 0) {
|
|
152
|
+
this.stop();
|
|
153
|
+
return;
|
|
154
|
+
} else {
|
|
155
|
+
this.run();
|
|
156
|
+
|
|
157
|
+
if (this.completions && this.completions !== 0) {
|
|
158
|
+
this.completions -= 1;
|
|
159
|
+
}
|
|
160
|
+
this.saveToStore();
|
|
161
|
+
}
|
|
162
|
+
}, this.duration);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Stops the interval
|
|
167
|
+
*/
|
|
168
|
+
stop() {
|
|
169
|
+
if (this.intervalId) {
|
|
170
|
+
clearInterval(this.intervalId);
|
|
171
|
+
this.intervalId = null;
|
|
172
|
+
}
|
|
173
|
+
this.store && this.store.removeItem(this.name);
|
|
174
|
+
}
|
|
175
|
+
}
|