diff --git a/docs/030_user-guide/130_filters.md b/docs/030_user-guide/130_filters.md index 3ea3d8fc2..5a2d81a6c 100644 --- a/docs/030_user-guide/130_filters.md +++ b/docs/030_user-guide/130_filters.md @@ -24,7 +24,9 @@ When(a.ConfigMap) ## `Filters` - `.WithName("name")`: Filters resources by name. +- `.WithNameRegex(/^pepr/)`: Filters resources by name using a regex. - `.InNamespace("namespace")`: Filters resources by namespace. +- `.InNamespaceRegex(/(.*)-system/)`: Filters resources by namespace using a regex. - `.WithLabel("key", "value")`: Filters resources by label. (Can be multiple) - `.WithDeletionTimestamp()`: Filters resources that have a deletion timestamp. diff --git a/src/lib/capability.ts b/src/lib/capability.ts index f62119da2..1473cc40b 100644 --- a/src/lib/capability.ts +++ b/src/lib/capability.ts @@ -199,6 +199,8 @@ export class Capability implements CapabilityExport { filters: { name: "", namespaces: [], + regexNamespaces: [], + regexName: "", labels: {}, annotations: {}, deletionTimestamp: false, @@ -312,7 +314,13 @@ export class Capability implements CapabilityExport { function InNamespace(...namespaces: string[]): BindingWithName { Log.debug(`Add namespaces filter ${namespaces}`, prefix); binding.filters.namespaces.push(...namespaces); - return { ...commonChain, WithName }; + return { ...commonChain, WithName, WithNameRegex }; + } + + function InNamespaceRegex(...namespaces: RegExp[]): BindingWithName { + Log.debug(`Add regex namespaces filter ${namespaces}`, prefix); + binding.filters.regexNamespaces.push(...namespaces.map(regex => regex.source)); + return { ...commonChain, WithName, WithNameRegex }; } function WithDeletionTimestamp(): BindingFilter { @@ -321,6 +329,12 @@ export class Capability implements CapabilityExport { return commonChain; } + function WithNameRegex(regexName: RegExp): BindingFilter { + Log.debug(`Add regex name filter ${regexName}`, prefix); + binding.filters.regexName = regexName.source; + return commonChain; + } + function WithName(name: string): BindingFilter { Log.debug(`Add name filter ${name}`, prefix); binding.filters.name = name; @@ -344,7 +358,9 @@ export class Capability implements CapabilityExport { return { ...commonChain, InNamespace, + InNamespaceRegex, WithName, + WithNameRegex, WithDeletionTimestamp, }; } diff --git a/src/lib/filter.test.ts b/src/lib/filter.test.ts index dac4f0a09..8aa358ccf 100644 --- a/src/lib/filter.test.ts +++ b/src/lib/filter.test.ts @@ -5,15 +5,15 @@ import { expect, test, describe } from "@jest/globals"; import { kind, modelToGroupVersionKind } from "kubernetes-fluent-client"; import * as fc from "fast-check"; import { CreatePod, DeletePod } from "../fixtures/loader"; -import { shouldSkipRequest } from "./filter"; +import { shouldSkipRequestRegex } from "./filter"; import { Event, Binding } from "./types"; import { AdmissionRequest } from "./k8s"; -const callback = () => undefined; +export const callback = () => undefined; -const podKind = modelToGroupVersionKind(kind.Pod.name); +export const podKind = modelToGroupVersionKind(kind.Pod.name); -describe("Fuzzing shouldSkipRequest", () => { +describe("Fuzzing shouldSkipRequestRegex", () => { test("should handle random inputs without crashing", () => { fc.assert( fc.property( @@ -51,7 +51,7 @@ describe("Fuzzing shouldSkipRequest", () => { fc.array(fc.string()), (binding, req, capabilityNamespaces) => { expect(() => - shouldSkipRequest(binding as Binding, req as AdmissionRequest, capabilityNamespaces), + shouldSkipRequestRegex(binding as Binding, req as AdmissionRequest, capabilityNamespaces), ).not.toThrow(); }, ), @@ -59,7 +59,7 @@ describe("Fuzzing shouldSkipRequest", () => { ); }); }); -describe("Property-Based Testing shouldSkipRequest", () => { +describe("Property-Based Testing shouldSkipRequestRegex", () => { test("should only skip requests that do not match the binding criteria", () => { fc.assert( fc.property( @@ -96,7 +96,7 @@ describe("Property-Based Testing shouldSkipRequest", () => { }), fc.array(fc.string()), (binding, req, capabilityNamespaces) => { - const shouldSkip = shouldSkipRequest(binding as Binding, req as AdmissionRequest, capabilityNamespaces); + const shouldSkip = shouldSkipRequestRegex(binding as Binding, req as AdmissionRequest, capabilityNamespaces); expect(typeof shouldSkip).toBe("boolean"); }, ), @@ -105,14 +105,35 @@ describe("Property-Based Testing shouldSkipRequest", () => { }); }); -test("should reject when name does not match", () => { +test("create: should reject when regex name does not match", () => { const binding = { model: kind.Pod, event: Event.Any, kind: podKind, filters: { - name: "bleh", + name: "", + namespaces: [], + regexNamespaces: [], + regexName: new RegExp(/^default$/).source, + labels: {}, + annotations: {}, + deletionTimestamp: false, + }, + callback, + }; + const pod = CreatePod(); + expect(shouldSkipRequestRegex(binding, pod, [])).toBe(true); +}); +test("create: should not reject when regex name does match", () => { + const binding = { + model: kind.Pod, + event: Event.Any, + kind: podKind, + filters: { + name: "", namespaces: [], + regexNamespaces: [], + regexName: new RegExp(/^cool/).source, labels: {}, annotations: {}, deletionTimestamp: false, @@ -120,10 +141,146 @@ test("should reject when name does not match", () => { callback, }; const pod = CreatePod(); + expect(shouldSkipRequestRegex(binding, pod, [])).toBe(false); +}); +test("delete: should reject when regex name does not match", () => { + const binding = { + model: kind.Pod, + event: Event.Any, + kind: podKind, + filters: { + name: "", + namespaces: [], + regexNamespaces: [], + regexName: new RegExp(/^default$/).source, + labels: {}, + annotations: {}, + deletionTimestamp: false, + }, + callback, + }; + const pod = DeletePod(); + expect(shouldSkipRequestRegex(binding, pod, [])).toBe(true); +}); +test("delete: should not reject when regex name does match", () => { + const binding = { + model: kind.Pod, + event: Event.Any, + kind: podKind, + filters: { + name: "", + namespaces: [], + regexNamespaces: [], + regexName: new RegExp(/^cool/).source, + labels: {}, + annotations: {}, + deletionTimestamp: false, + }, + callback, + }; + const pod = DeletePod(); + expect(shouldSkipRequestRegex(binding, pod, [])).toBe(false); +}); - expect(shouldSkipRequest(binding, pod, [])).toBe(true); +test("create: should not reject when regex namespace does match", () => { + const binding = { + model: kind.Pod, + event: Event.Any, + kind: podKind, + filters: { + name: "", + namespaces: [], + regexNamespaces: [new RegExp(/^helm/).source], + regexName: "", + labels: {}, + annotations: {}, + deletionTimestamp: false, + }, + callback, + }; + const pod = CreatePod(); + expect(shouldSkipRequestRegex(binding, pod, [])).toBe(false); }); +test("create: should reject when regex namespace does not match", () => { + const binding = { + model: kind.Pod, + event: Event.Any, + kind: podKind, + filters: { + name: "", + namespaces: [], + regexNamespaces: [new RegExp(/^argo/).source], + regexName: "", + labels: {}, + annotations: {}, + deletionTimestamp: false, + }, + callback, + }; + const pod = CreatePod(); + expect(shouldSkipRequestRegex(binding, pod, [])).toBe(true); +}); + +test("delete: should reject when regex namespace does not match", () => { + const binding = { + model: kind.Pod, + event: Event.Any, + kind: podKind, + filters: { + name: "bleh", + namespaces: [], + regexNamespaces: [new RegExp(/^argo/).source], + regexName: "", + labels: {}, + annotations: {}, + deletionTimestamp: false, + }, + callback, + }; + const pod = DeletePod(); + expect(shouldSkipRequestRegex(binding, pod, [])).toBe(true); +}); + +test("delete: should not reject when regex namespace does match", () => { + const binding = { + model: kind.Pod, + event: Event.Any, + kind: podKind, + filters: { + name: "", + namespaces: [], + regexNamespaces: [new RegExp(/^helm/).source], + regexName: "", + labels: {}, + annotations: {}, + deletionTimestamp: false, + }, + callback, + }; + const pod = DeletePod(); + expect(shouldSkipRequestRegex(binding, pod, [])).toBe(false); +}); + +test("delete: should reject when name does not match", () => { + const binding = { + model: kind.Pod, + event: Event.Any, + kind: podKind, + filters: { + name: "bleh", + namespaces: [], + regexNamespaces: [], + regexName: new RegExp(/^not-cool/).source, + labels: {}, + annotations: {}, + deletionTimestamp: false, + }, + callback, + }; + const pod = DeletePod(); + expect(shouldSkipRequestRegex(binding, pod, [])).toBe(true); +}); test("should reject when kind does not match", () => { const binding = { model: kind.Pod, @@ -132,6 +289,8 @@ test("should reject when kind does not match", () => { filters: { name: "", namespaces: [], + regexNamespaces: [], + regexName: "", labels: {}, annotations: {}, deletionTimestamp: false, @@ -140,7 +299,7 @@ test("should reject when kind does not match", () => { }; const pod = CreatePod(); - expect(shouldSkipRequest(binding, pod, [])).toBe(true); + expect(shouldSkipRequestRegex(binding, pod, [])).toBe(true); }); test("should reject when group does not match", () => { @@ -154,12 +313,14 @@ test("should reject when group does not match", () => { labels: {}, annotations: {}, deletionTimestamp: false, + regexNamespaces: [], + regexName: "", }, callback, }; const pod = CreatePod(); - expect(shouldSkipRequest(binding, pod, [])).toBe(true); + expect(shouldSkipRequestRegex(binding, pod, [])).toBe(true); }); test("should reject when version does not match", () => { @@ -177,12 +338,14 @@ test("should reject when version does not match", () => { labels: {}, annotations: {}, deletionTimestamp: false, + regexNamespaces: [], + regexName: "", }, callback, }; const pod = CreatePod(); - expect(shouldSkipRequest(binding, pod, [])).toBe(true); + expect(shouldSkipRequestRegex(binding, pod, [])).toBe(true); }); test("should allow when group, version, and kind match", () => { @@ -196,12 +359,14 @@ test("should allow when group, version, and kind match", () => { labels: {}, annotations: {}, deletionTimestamp: false, + regexNamespaces: [], + regexName: "", }, callback, }; const pod = CreatePod(); - expect(shouldSkipRequest(binding, pod, [])).toBe(false); + expect(shouldSkipRequestRegex(binding, pod, [])).toBe(false); }); test("should allow when kind match and others are empty", () => { @@ -219,15 +384,17 @@ test("should allow when kind match and others are empty", () => { labels: {}, annotations: {}, deletionTimestamp: false, + regexNamespaces: [], + regexName: "", }, callback, }; const pod = CreatePod(); - expect(shouldSkipRequest(binding, pod, [])).toBe(false); + expect(shouldSkipRequestRegex(binding, pod, [])).toBe(false); }); -test("should reject when teh capability namespace does not match", () => { +test("should reject when the capability namespace does not match", () => { const binding = { model: kind.Pod, event: Event.Any, @@ -238,12 +405,14 @@ test("should reject when teh capability namespace does not match", () => { labels: {}, annotations: {}, deletionTimestamp: false, + regexNamespaces: [], + regexName: "", }, callback, }; const pod = CreatePod(); - expect(shouldSkipRequest(binding, pod, ["bleh", "bleh2"])).toBe(true); + expect(shouldSkipRequestRegex(binding, pod, ["bleh", "bleh2"])).toBe(true); }); test("should reject when namespace does not match", () => { @@ -257,12 +426,14 @@ test("should reject when namespace does not match", () => { labels: {}, annotations: {}, deletionTimestamp: false, + regexNamespaces: [], + regexName: "", }, callback, }; const pod = CreatePod(); - expect(shouldSkipRequest(binding, pod, [])).toBe(true); + expect(shouldSkipRequestRegex(binding, pod, [])).toBe(true); }); test("should allow when namespace is match", () => { @@ -276,12 +447,14 @@ test("should allow when namespace is match", () => { labels: {}, annotations: {}, deletionTimestamp: false, + regexNamespaces: [], + regexName: "", }, callback, }; const pod = CreatePod(); - expect(shouldSkipRequest(binding, pod, [])).toBe(false); + expect(shouldSkipRequestRegex(binding, pod, [])).toBe(false); }); test("should reject when label does not match", () => { @@ -297,12 +470,14 @@ test("should reject when label does not match", () => { }, annotations: {}, deletionTimestamp: false, + regexNamespaces: [], + regexName: "", }, callback, }; const pod = CreatePod(); - expect(shouldSkipRequest(binding, pod, [])).toBe(true); + expect(shouldSkipRequestRegex(binding, pod, [])).toBe(true); }); test("should allow when label is match", () => { @@ -314,6 +489,8 @@ test("should allow when label is match", () => { name: "", deletionTimestamp: false, namespaces: [], + regexNamespaces: [], + regexName: "", labels: { foo: "bar", test: "test1", @@ -331,7 +508,7 @@ test("should allow when label is match", () => { test2: "test2", }; - expect(shouldSkipRequest(binding, pod, [])).toBe(false); + expect(shouldSkipRequestRegex(binding, pod, [])).toBe(false); }); test("should reject when annotation does not match", () => { @@ -347,12 +524,14 @@ test("should reject when annotation does not match", () => { foo: "bar", }, deletionTimestamp: false, + regexNamespaces: [], + regexName: "", }, callback, }; const pod = CreatePod(); - expect(shouldSkipRequest(binding, pod, [])).toBe(true); + expect(shouldSkipRequestRegex(binding, pod, [])).toBe(true); }); test("should allow when annotation is match", () => { @@ -369,6 +548,8 @@ test("should allow when annotation is match", () => { test: "test1", }, deletionTimestamp: false, + regexNamespaces: [], + regexName: "", }, callback, }; @@ -381,7 +562,7 @@ test("should allow when annotation is match", () => { test2: "test2", }; - expect(shouldSkipRequest(binding, pod, [])).toBe(false); + expect(shouldSkipRequestRegex(binding, pod, [])).toBe(false); }); test("should use `oldObject` when the operation is `DELETE`", () => { @@ -392,6 +573,8 @@ test("should use `oldObject` when the operation is `DELETE`", () => { filters: { name: "", namespaces: [], + regexNamespaces: [], + regexName: "", deletionTimestamp: false, labels: { "app.kubernetes.io/name": "cool-name-podinfo", @@ -405,7 +588,7 @@ test("should use `oldObject` when the operation is `DELETE`", () => { const pod = DeletePod(); - expect(shouldSkipRequest(binding, pod, [])).toBe(false); + expect(shouldSkipRequestRegex(binding, pod, [])).toBe(false); }); test("should skip processing when deletionTimestamp is not present on pod", () => { @@ -417,6 +600,8 @@ test("should skip processing when deletionTimestamp is not present on pod", () = name: "", namespaces: [], labels: {}, + regexNamespaces: [], + regexName: "", annotations: { foo: "bar", test: "test1", @@ -434,7 +619,7 @@ test("should skip processing when deletionTimestamp is not present on pod", () = test2: "test2", }; - expect(shouldSkipRequest(binding, pod, [])).toBe(true); + expect(shouldSkipRequestRegex(binding, pod, [])).toBe(true); }); test("should processing when deletionTimestamp is not present on pod", () => { @@ -446,6 +631,8 @@ test("should processing when deletionTimestamp is not present on pod", () => { name: "", namespaces: [], labels: {}, + regexNamespaces: [], + regexName: "", annotations: { foo: "bar", test: "test1", @@ -464,5 +651,5 @@ test("should processing when deletionTimestamp is not present on pod", () => { test2: "test2", }; - expect(shouldSkipRequest(binding, pod, [])).toBe(false); + expect(shouldSkipRequestRegex(binding, pod, [])).toBe(false); }); diff --git a/src/lib/filter.ts b/src/lib/filter.ts index 86988e3f5..71281ce13 100644 --- a/src/lib/filter.ts +++ b/src/lib/filter.ts @@ -1,10 +1,43 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: 2023-Present The Pepr Authors +import { matchesRegex } from "./helpers"; import { AdmissionRequest, Operation } from "./k8s"; import logger from "./logger"; import { Binding, Event } from "./types"; +export function shouldSkipRequestRegex(binding: Binding, req: AdmissionRequest, capabilityNamespaces: string[]) { + const { regexNamespaces, regexName } = binding.filters || {}; + const result = shouldSkipRequest(binding, req, capabilityNamespaces); + const operation = req.operation.toUpperCase(); + if (!result) { + if (regexNamespaces && regexNamespaces.length > 0) { + for (const regexNamespace of regexNamespaces) { + if ( + !matchesRegex( + regexNamespace, + (operation === Operation.DELETE ? req.oldObject?.metadata?.namespace : req.object.metadata?.namespace) || + "", + ) + ) { + return true; + } + } + } + + if ( + regexName && + regexName !== "" && + !matchesRegex( + regexName, + (operation === Operation.DELETE ? req.oldObject?.metadata?.name : req.object.metadata?.name) || "", + ) + ) { + return true; + } + } + return result; +} /** * shouldSkipRequest determines if a request should be skipped based on the binding filters. * diff --git a/src/lib/helpers.test.ts b/src/lib/helpers.test.ts index c9d97c49d..14f5a8b73 100644 --- a/src/lib/helpers.test.ts +++ b/src/lib/helpers.test.ts @@ -1,15 +1,16 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: 2023-Present The Pepr Authors -import { Binding, CapabilityExport } from "./types"; +import { Binding, CapabilityExport, Event } from "./types"; import { createRBACMap, addVerbIfNotExists, checkOverlap, - filterNoMatchReason, + filterNoMatchReasonRegex, validateHash, ValidationError, validateCapabilityNames, + matchesRegex, } from "./helpers"; import { sanitizeResourceName } from "../sdk/sdk"; import * as fc from "fast-check"; @@ -29,10 +30,12 @@ import { } from "./helpers"; import { SpiedFunction } from "jest-mock"; -import { K8s, GenericClass, KubernetesObject } from "kubernetes-fluent-client"; +import { K8s, GenericClass, KubernetesObject, kind } from "kubernetes-fluent-client"; import { K8sInit } from "kubernetes-fluent-client/dist/fluent/types"; import { checkDeploymentStatus, namespaceDeploymentsReady } from "./helpers"; +export const callback = () => undefined; + jest.mock("kubernetes-fluent-client", () => { return { K8s: jest.fn(), @@ -606,8 +609,76 @@ describe("namespaceComplianceValidator", () => { afterEach(() => { errorSpy.mockRestore(); }); + test("should throw error for invalid regex namespaces", () => { + const nsViolationCapability: CapabilityExport = { + ...nonNsViolation[0], + bindings: nonNsViolation[0].bindings.map(binding => ({ + ...binding, + filters: { + ...binding.filters, + namespaces: [], + regexNamespaces: [new RegExp(/^system/).source], + }, + })), + }; + expect(() => { + namespaceComplianceValidator(nsViolationCapability); + }).toThrowError( + `Ignoring Watch Callback: Object namespace does not match any capability namespace with regex ${nsViolationCapability.bindings[0].filters.regexNamespaces[0]}.`, + ); + }); + test("should not throw an error for valid regex namespaces", () => { + const nonnsViolationCapability: CapabilityExport = { + ...nonNsViolation[0], + bindings: nonNsViolation[0].bindings.map(binding => ({ + ...binding, + filters: { + ...binding.filters, + namespaces: [], + regexNamespaces: [new RegExp(/^mia/).source], + }, + })), + }; + expect(() => { + namespaceComplianceValidator(nonnsViolationCapability); + }).not.toThrow(); + }); - test("should not throw an error for invalid namespaces", () => { + test("should throw error for invalid regex ignored namespaces", () => { + const nsViolationCapability: CapabilityExport = { + ...nonNsViolation[0], + bindings: nonNsViolation[0].bindings.map(binding => ({ + ...binding, + filters: { + ...binding.filters, + namespaces: [], + regexNamespaces: [new RegExp(/^mia/).source], + }, + })), + }; + expect(() => { + namespaceComplianceValidator(nsViolationCapability, ["miami"]); + }).toThrowError( + `Ignoring Watch Callback: Regex namespace: ${nsViolationCapability.bindings[0].filters.regexNamespaces[0]}, is an ignored namespace: miami.`, + ); + }); + test("should not throw an error for valid regex ignored namespaces", () => { + const nonnsViolationCapability: CapabilityExport = { + ...nonNsViolation[0], + bindings: nonNsViolation[0].bindings.map(binding => ({ + ...binding, + filters: { + ...binding.filters, + namespaces: [], + regexNamespaces: [new RegExp(/^mia/).source], + }, + })), + }; + expect(() => { + namespaceComplianceValidator(nonnsViolationCapability, ["Seattle"]); + }).not.toThrow(); + }); + test("should not throw an error for valid namespaces", () => { expect(() => { namespaceComplianceValidator(nonNsViolation[0]); }).not.toThrow(); @@ -1041,6 +1112,108 @@ describe("checkOverlap", () => { }); describe("filterMatcher", () => { + test("returns regex namespace filter error for Pods whos namespace does not match the regex", () => { + const binding = { + kind: { kind: "Pod" }, + filters: { regexNamespaces: [/(.*)-system/], namespaces: [] }, + }; + const obj = { metadata: { namespace: "pepr-demo" } }; + const objArray = [ + { ...obj, metadata: { namespace: "pepr-demo" } }, + { ...obj, metadata: { namespace: "pepr-uds" } }, + { ...obj, metadata: { namespace: "pepr-core" } }, + { ...obj, metadata: { namespace: "uds-ns" } }, + { ...obj, metadata: { namespace: "uds" } }, + ]; + const capabilityNamespaces: string[] = []; + objArray.map(object => { + const result = filterNoMatchReasonRegex( + binding as unknown as Partial, + object as unknown as Partial, + capabilityNamespaces, + ); + expect(result).toEqual( + `Ignoring Watch Callback: Object namespace ${object.metadata?.namespace} does not match regex ${binding.filters.regexNamespaces[0]}.`, + ); + }); + }); + + test("returns no regex namespace filter error for Pods whos namespace does match the regex", () => { + const binding = { + kind: { kind: "Pod" }, + filters: { regexNamespaces: [/(.*)-system/], namespaces: [] }, + }; + const obj = { metadata: { namespace: "pepr-demo" } }; + const objArray = [ + { ...obj, metadata: { namespace: "pepr-system" } }, + { ...obj, metadata: { namespace: "pepr-uds-system" } }, + { ...obj, metadata: { namespace: "uds-system" } }, + { ...obj, metadata: { namespace: "some-thing-that-is-a-system" } }, + { ...obj, metadata: { namespace: "your-system" } }, + ]; + const capabilityNamespaces: string[] = []; + objArray.map(object => { + const result = filterNoMatchReasonRegex( + binding as unknown as Partial, + object as unknown as Partial, + capabilityNamespaces, + ); + expect(result).toEqual(``); + }); + }); + + // Names Fail + test("returns regex name filter error for Pods whos name does not match the regex", () => { + const binding = { + kind: { kind: "Pod" }, + filters: { regexName: /^system/, namespaces: [] }, + }; + const obj = { metadata: { name: "pepr-demo" } }; + const objArray = [ + { ...obj, metadata: { name: "pepr-demo" } }, + { ...obj, metadata: { name: "pepr-uds" } }, + { ...obj, metadata: { name: "pepr-core" } }, + { ...obj, metadata: { name: "uds-ns" } }, + { ...obj, metadata: { name: "uds" } }, + ]; + const capabilityNamespaces: string[] = []; + objArray.map(object => { + const result = filterNoMatchReasonRegex( + binding as unknown as Partial, + object as unknown as Partial, + capabilityNamespaces, + ); + expect(result).toEqual( + `Ignoring Watch Callback: Object name ${object.metadata?.name} does not match regex ${binding.filters.regexName}.`, + ); + }); + }); + + // Names Pass + test("returns no regex name filter error for Pods whos name does match the regex", () => { + const binding = { + kind: { kind: "Pod" }, + filters: { regexName: /^system/, namespaces: [] }, + }; + const obj = { metadata: { name: "pepr-demo" } }; + const objArray = [ + { ...obj, metadata: { name: "systemd" } }, + { ...obj, metadata: { name: "systemic" } }, + { ...obj, metadata: { name: "system-of-kube-apiserver" } }, + { ...obj, metadata: { name: "system" } }, + { ...obj, metadata: { name: "system-uds" } }, + ]; + const capabilityNamespaces: string[] = []; + objArray.map(object => { + const result = filterNoMatchReasonRegex( + binding as unknown as Partial, + object as unknown as Partial, + capabilityNamespaces, + ); + expect(result).toEqual(``); + }); + }); + test("returns namespace filter error for namespace objects with namespace filters", () => { const binding = { kind: { kind: "Namespace" }, @@ -1048,7 +1221,7 @@ describe("filterMatcher", () => { }; const obj = {}; const capabilityNamespaces: string[] = []; - const result = filterNoMatchReason( + const result = filterNoMatchReasonRegex( binding as unknown as Partial, obj as unknown as Partial, capabilityNamespaces, @@ -1066,7 +1239,7 @@ describe("filterMatcher", () => { }, }; const capabilityNamespaces: string[] = []; - const result = filterNoMatchReason( + const result = filterNoMatchReasonRegex( binding as unknown as Partial, obj as unknown as Partial, capabilityNamespaces, @@ -1083,7 +1256,7 @@ describe("filterMatcher", () => { metadata: { name: "pepr" }, }; const capabilityNamespaces: string[] = []; - const result = filterNoMatchReason( + const result = filterNoMatchReasonRegex( binding as unknown as Partial, obj as unknown as Partial, capabilityNamespaces, @@ -1099,7 +1272,7 @@ describe("filterMatcher", () => { metadata: {}, }; const capabilityNamespaces: string[] = []; - const result = filterNoMatchReason( + const result = filterNoMatchReasonRegex( binding as unknown as Partial, obj as unknown as Partial, capabilityNamespaces, @@ -1117,7 +1290,7 @@ describe("filterMatcher", () => { }, }; const capabilityNamespaces: string[] = []; - const result = filterNoMatchReason( + const result = filterNoMatchReasonRegex( binding as unknown as Partial, obj as unknown as Partial, capabilityNamespaces, @@ -1133,7 +1306,7 @@ describe("filterMatcher", () => { metadata: { labels: { anotherKey: "anotherValue" } }, }; const capabilityNamespaces: string[] = []; - const result = filterNoMatchReason( + const result = filterNoMatchReasonRegex( binding as unknown as Partial, obj as unknown as Partial, capabilityNamespaces, @@ -1151,7 +1324,7 @@ describe("filterMatcher", () => { metadata: { annotations: { anotherKey: "anotherValue" } }, }; const capabilityNamespaces: string[] = []; - const result = filterNoMatchReason( + const result = filterNoMatchReasonRegex( binding as unknown as Partial, obj as unknown as Partial, capabilityNamespaces, @@ -1162,13 +1335,32 @@ describe("filterMatcher", () => { }); test("returns capability namespace error when object is not in capability namespaces", () => { - const binding = {}; + const binding = { + model: kind.Pod, + event: Event.Any, + kind: { + group: "", + version: "v1", + kind: "Pod", + }, + filters: { + name: "bleh", + namespaces: [], + regexNamespaces: [], + regexName: "", + labels: {}, + annotations: {}, + deletionTimestamp: false, + }, + callback, + }; + const obj = { - metadata: { namespace: "ns2" }, + metadata: { namespace: "ns2", name: "bleh" }, }; const capabilityNamespaces = ["ns1"]; - const result = filterNoMatchReason( - binding as unknown as Partial, + const result = filterNoMatchReasonRegex( + binding as Binding, obj as unknown as Partial, capabilityNamespaces, ); @@ -1179,11 +1371,11 @@ describe("filterMatcher", () => { test("returns binding namespace error when filter namespace is not part of capability namespaces", () => { const binding = { - filters: { namespaces: ["ns3"] }, + filters: { namespaces: ["ns3"], regexNamespaces: [] }, }; const obj = {}; const capabilityNamespaces = ["ns1", "ns2"]; - const result = filterNoMatchReason( + const result = filterNoMatchReasonRegex( binding as unknown as Partial, obj as unknown as Partial, capabilityNamespaces, @@ -1195,13 +1387,13 @@ describe("filterMatcher", () => { test("returns binding and object namespace error when they do not overlap", () => { const binding = { - filters: { namespaces: ["ns1"] }, + filters: { namespaces: ["ns1"], regexNamespaces: [] }, }; const obj = { metadata: { namespace: "ns2" }, }; const capabilityNamespaces = ["ns1", "ns2"]; - const result = filterNoMatchReason( + const result = filterNoMatchReasonRegex( binding as unknown as Partial, obj as unknown as Partial, capabilityNamespaces, @@ -1219,7 +1411,7 @@ describe("filterMatcher", () => { metadata: { namespace: "ns1", labels: { key: "value" }, annotations: { key: "value" } }, }; const capabilityNamespaces = ["ns1"]; - const result = filterNoMatchReason( + const result = filterNoMatchReasonRegex( binding as unknown as Partial, obj as unknown as Partial, capabilityNamespaces, @@ -1259,3 +1451,64 @@ describe("validateHash", () => { expect(() => validateHash(validHash)).not.toThrow(); }); }); + +describe("matchesRegex", () => { + test("should return true for a valid pattern that matches the string", () => { + const pattern = /abc/; + const testString = "abc123"; + const result = matchesRegex(new RegExp(pattern).source, testString); + expect(result).toBe(true); + }); + + test("should return false for a valid pattern that does not match the string", () => { + const pattern = /xyz/; + const testString = "abc123"; + const result = matchesRegex(new RegExp(pattern).source, testString); + expect(result).toBe(false); + }); + + test("should return false for an invalid regex pattern", () => { + const invalidPattern = new RegExp(/^p/); // Invalid regex with unclosed bracket + const testString = "test"; + const result = matchesRegex(invalidPattern.source, testString); + expect(result).toBe(false); + }); + + test("should return false when pattern is null or undefined", () => { + const testString = "abc123"; + // Check for undefined + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(matchesRegex(undefined as any, testString)).toBe(false); + // Check for null + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(matchesRegex(null as any, testString)).toBe(false); + }); + + test("should return true for an empty string matching an empty regex", () => { + const pattern = new RegExp(""); + const testString = ""; + const result = matchesRegex(new RegExp(pattern).source, testString); + expect(result).toBe(true); + }); + + test("should return false for an empty string and a non-empty regex", () => { + const pattern = new RegExp("abc"); + const testString = ""; + const result = matchesRegex(new RegExp(pattern).source, testString); + expect(result).toBe(false); + }); + + test("should return true for a complex valid regex that matches", () => { + const pattern = /^[a-zA-Z0-9]+@[a-zA-Z0-9]+\.[A-Za-z]+$/; + const testString = "test@example.com"; + const result = matchesRegex(new RegExp(pattern).source, testString); + expect(result).toBe(true); + }); + + test("should return false for a complex valid regex that does not match", () => { + const pattern = /^[a-zA-Z0-9]+@[a-zA-Z0-9]+\.[A-Za-z]+$/; + const testString = "invalid-email.com"; + const result = matchesRegex(new RegExp(pattern).source, testString); + expect(result).toBe(false); + }); +}); diff --git a/src/lib/helpers.ts b/src/lib/helpers.ts index 49fa797d9..a687b7cfb 100644 --- a/src/lib/helpers.ts +++ b/src/lib/helpers.ts @@ -7,6 +7,16 @@ import Log from "./logger"; import { Binding, CapabilityExport } from "./types"; import { sanitizeResourceName } from "../sdk/sdk"; +export function matchesRegex(pattern: string, testString: string): boolean { + // edge-case + if (!pattern) { + return false; + } + + const regex = new RegExp(pattern); + return regex.test(testString); +} + export class ValidationError extends Error {} export function validateCapabilityNames(capabilities: CapabilityExport[] | undefined): void { @@ -65,6 +75,27 @@ export function checkOverlap(bindingFilters: Record, objectFilte return matchCount === Object.keys(bindingFilters).length; } +export function filterNoMatchReasonRegex( + binding: Partial, + obj: Partial, + capabilityNamespaces: string[], +): string { + const { regexNamespaces, regexName } = binding.filters || {}; + const result = filterNoMatchReason(binding, obj, capabilityNamespaces); + if (result === "") { + if (Array.isArray(regexNamespaces) && regexNamespaces.length > 0) { + for (const regexNamespace of regexNamespaces) { + if (!matchesRegex(regexNamespace, obj.metadata?.namespace || "")) { + return `Ignoring Watch Callback: Object namespace ${obj.metadata?.namespace} does not match regex ${regexNamespace}.`; + } + } + } + if (regexName && regexName !== "" && !matchesRegex(regexName, obj.metadata?.name || "")) { + return `Ignoring Watch Callback: Object name ${obj.metadata?.name} does not match regex ${regexName}.`; + } + } + return result; +} /** * Decide to run callback after the event comes back from API Server **/ @@ -249,7 +280,8 @@ export function generateWatchNamespaceError( // namespaceComplianceValidator ensures that capability bindinds respect ignored and capability namespaces export function namespaceComplianceValidator(capability: CapabilityExport, ignoredNamespaces?: string[]) { const { namespaces: capabilityNamespaces, bindings, name } = capability; - const bindingNamespaces = bindings.flatMap(binding => binding.filters.namespaces); + const bindingNamespaces = bindings.flatMap((binding: Binding) => binding.filters.namespaces); + const bindingRegexNamespaces = bindings.flatMap((binding: Binding) => binding.filters.regexNamespaces || []); const namespaceError = generateWatchNamespaceError( ignoredNamespaces ? ignoredNamespaces : [], @@ -261,6 +293,47 @@ export function namespaceComplianceValidator(capability: CapabilityExport, ignor `Error in ${name} capability. A binding violates namespace rules. Please check ignoredNamespaces and capability namespaces: ${namespaceError}`, ); } + + // Ensure that each regexNamespace matches a capabilityNamespace + + if ( + bindingRegexNamespaces && + bindingRegexNamespaces.length > 0 && + capabilityNamespaces && + capabilityNamespaces.length > 0 + ) { + for (const regexNamespace of bindingRegexNamespaces) { + let matches = false; + for (const capabilityNamespace of capabilityNamespaces) { + if (regexNamespace !== "" && matchesRegex(regexNamespace, capabilityNamespace)) { + matches = true; + break; + } + } + if (!matches) { + throw new Error( + `Ignoring Watch Callback: Object namespace does not match any capability namespace with regex ${regexNamespace}.`, + ); + } + } + } + // ensure regexNamespaces do not match ignored ns + if ( + bindingRegexNamespaces && + bindingRegexNamespaces.length > 0 && + ignoredNamespaces && + ignoredNamespaces.length > 0 + ) { + for (const regexNamespace of bindingRegexNamespaces) { + for (const ignoredNS of ignoredNamespaces) { + if (matchesRegex(regexNamespace, ignoredNS)) { + throw new Error( + `Ignoring Watch Callback: Regex namespace: ${regexNamespace}, is an ignored namespace: ${ignoredNS}.`, + ); + } + } + } + } } // check to see if all replicas are ready for all deployments in the pepr-system namespace diff --git a/src/lib/mutate-processor.ts b/src/lib/mutate-processor.ts index 7f6ff2713..a572118db 100644 --- a/src/lib/mutate-processor.ts +++ b/src/lib/mutate-processor.ts @@ -6,7 +6,7 @@ import { kind } from "kubernetes-fluent-client"; import { Capability } from "./capability"; import { Errors } from "./errors"; -import { shouldSkipRequest } from "./filter"; +import { shouldSkipRequestRegex } from "./filter"; import { MutateResponse, AdmissionRequest } from "./k8s"; import Log from "./logger"; import { ModuleConfig } from "./module"; @@ -49,7 +49,7 @@ export async function mutateProcessor( } // Continue to the next action without doing anything if this one should be skipped - if (shouldSkipRequest(action, req, namespaces)) { + if (shouldSkipRequestRegex(action, req, namespaces)) { continue; } diff --git a/src/lib/types.ts b/src/lib/types.ts index 5920d85a9..b834c7847 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -81,7 +81,10 @@ export type WhenSelector = { /** Register an action to be executed when a Kubernetes resource is deleted. */ IsDeleted: () => BindingAll; }; - +export interface RegExpFilter { + obj: RegExp; + source: string; +} export type Binding = { event: Event; isMutate?: boolean; @@ -93,7 +96,9 @@ export type Binding = { readonly kind: GroupVersionKind; readonly filters: { name: string; + regexName: string; namespaces: string[]; + regexNamespaces: string[]; labels: Record; annotations: Record; deletionTimestamp: boolean; @@ -148,11 +153,15 @@ export type BindingFilter = CommonActionChain & { export type BindingWithName = BindingFilter & { /** Only apply the action if the resource name matches the specified name. */ WithName: (name: string) => BindingFilter; + /** Only apply the action if the resource name matches the specified regex name. */ + WithNameRegex: (name: RegExp) => BindingFilter; }; export type BindingAll = BindingWithName & { /** Only apply the action if the resource is in one of the specified namespaces.*/ InNamespace: (...namespaces: string[]) => BindingWithName; + /** Only apply the action if the resource is in one of the specified regex namespaces.*/ + InNamespaceRegex: (...namespaces: RegExp[]) => BindingWithName; }; export type CommonActionChain = MutateActionChain & { diff --git a/src/lib/validate-processor.ts b/src/lib/validate-processor.ts index c850f2ccb..087f6dba7 100644 --- a/src/lib/validate-processor.ts +++ b/src/lib/validate-processor.ts @@ -4,7 +4,7 @@ import { kind } from "kubernetes-fluent-client"; import { Capability } from "./capability"; -import { shouldSkipRequest } from "./filter"; +import { shouldSkipRequestRegex } from "./filter"; import { AdmissionRequest, ValidateResponse } from "./k8s"; import Log from "./logger"; import { convertFromBase64Map } from "./utils"; @@ -41,7 +41,7 @@ export async function validateProcessor( }; // Continue to the next action without doing anything if this one should be skipped - if (shouldSkipRequest(action, req, namespaces)) { + if (shouldSkipRequestRegex(action, req, namespaces)) { continue; }