Skip to content

Commit

Permalink
feat: withdeletiontimestamp filter (#1026)
Browse files Browse the repository at this point in the history
## Description

Adds a new filter `.WithDeletionTimestamp` to aid Operator developers as
they use `ownerRefs`. Decide if `.HasDeletionTimestamp` needs to be
done.

Needs:
- [x] e2e test
(defenseunicorns/pepr-excellent-examples#61)
- [x] docs 

## Related Issue

Fixes #1018 
Fixes: #1027 
<!-- or -->
Relates to #

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Other (security config, docs update, etc)

## Checklist before merging

- [ ] Test, docs, adr added or updated as needed
- [ ] [Contributor Guide
Steps](https://docs.pepr.dev/main/contribute/#submitting-a-pull-request)
followed

---------

Signed-off-by: Case Wylie <[email protected]>
Co-authored-by: Barrett <[email protected]>
  • Loading branch information
cmwylie19 and btlghrants authored Aug 30, 2024
1 parent 8cd1cb1 commit a5ae3cc
Show file tree
Hide file tree
Showing 8 changed files with 180 additions and 4 deletions.
4 changes: 2 additions & 2 deletions codecov.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ coverage:
project:
default:
target: auto
threshold: 10%
threshold: 30%
patch: # Settings for PR patches
default:
target: auto
threshold: 10% # Adjust as needed
threshold: 30% # Adjust as needed
33 changes: 33 additions & 0 deletions docs/030_user-guide/130_filters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Pepr Filters

Filters are functions that take a `AdmissionReview` or Watch event and return a boolean. They are used to filter out resources that do not meet certain criteria. Filters are used in the package to filter out resources that are not relevant to the user-defined admission or watch process.

```ts
When(a.ConfigMap)
// This limits the action to only act on new resources.
.IsCreated()
// Namespace filter
.InNamespace("webapp")
// Name filter
.WithName("example-1")
// Label filter
.WithLabel("app", "webapp")
.WithLabel("env", "prod")
.Mutate(request => {
request
.SetLabel("pepr", "was-here")
.SetAnnotation("pepr.dev", "annotations-work-too");
});
```


## `Filters`

- `.WithName("name")`: Filters resources by name.
- `.InNamespace("namespace")`: Filters resources by namespace.
- `.WithLabel("key", "value")`: Filters resources by label. (Can be multiple)
- `.WithDeletionTimestamp()`: Filters resources that have a deletion timestamp.

Notes:
- `WithDeletionTimestamp()` is does not work on Delete through the `Mutate` or `Validate` methods because the Kubernetes Admission Process does not fire the DELETE event with a deletion timestamp on the resource.
- `WithDeletionTimestamp()` _will_ match on an Update event during Admission (`Mutate` or `Validate`) when pending-deletion permitted changes (like removing a finalizer) occur.
10 changes: 9 additions & 1 deletion src/lib/capability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,12 +198,13 @@ export class Capability implements CapabilityExport {
namespaces: [],
labels: {},
annotations: {},
deletionTimestamp: false,
},
};

const bindings = this.#bindings;
const prefix = `${this.#name}: ${model.name}`;
const commonChain = { WithLabel, WithAnnotation, Mutate, Validate, Watch, Reconcile };
const commonChain = { WithLabel, WithAnnotation, WithDeletionTimestamp, Mutate, Validate, Watch, Reconcile };
const isNotEmpty = (value: object) => Object.keys(value).length > 0;
const log = (message: string, cbString: string) => {
const filteredObj = pickBy(isNotEmpty, binding.filters);
Expand Down Expand Up @@ -277,6 +278,12 @@ export class Capability implements CapabilityExport {
return { ...commonChain, WithName };
}

function WithDeletionTimestamp(): BindingFilter<T> {
Log.debug("Add deletionTimestamp filter");
binding.filters.deletionTimestamp = true;
return commonChain;
}

function WithName(name: string): BindingFilter<T> {
Log.debug(`Add name filter ${name}`, prefix);
binding.filters.name = name;
Expand All @@ -301,6 +308,7 @@ export class Capability implements CapabilityExport {
...commonChain,
InNamespace,
WithName,
WithDeletionTimestamp,
};
}

Expand Down
86 changes: 85 additions & 1 deletion src/lib/filter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ describe("Fuzzing shouldSkipRequest", () => {
namespaces: fc.array(fc.string()),
labels: fc.dictionary(fc.string(), fc.string()),
annotations: fc.dictionary(fc.string(), fc.string()),
deletionTimestamp: fc.boolean(),
}),
}),
fc.record({
Expand All @@ -41,6 +42,11 @@ describe("Fuzzing shouldSkipRequest", () => {
version: fc.string(),
kind: fc.string(),
}),
object: fc.record({
metadata: fc.record({
deletionTimestamp: fc.option(fc.date()),
}),
}),
}),
fc.array(fc.string()),
(binding, req, capabilityNamespaces) => {
Expand Down Expand Up @@ -69,6 +75,7 @@ describe("Property-Based Testing shouldSkipRequest", () => {
namespaces: fc.array(fc.string()),
labels: fc.dictionary(fc.string(), fc.string()),
annotations: fc.dictionary(fc.string(), fc.string()),
deletionTimestamp: fc.boolean(),
}),
}),
fc.record({
Expand All @@ -81,6 +88,11 @@ describe("Property-Based Testing shouldSkipRequest", () => {
version: fc.string(),
kind: fc.string(),
}),
object: fc.record({
metadata: fc.record({
deletionTimestamp: fc.option(fc.date()),
}),
}),
}),
fc.array(fc.string()),
(binding, req, capabilityNamespaces) => {
Expand All @@ -103,6 +115,7 @@ test("should reject when name does not match", () => {
namespaces: [],
labels: {},
annotations: {},
deletionTimestamp: false,
},
callback,
};
Expand All @@ -121,6 +134,7 @@ test("should reject when kind does not match", () => {
namespaces: [],
labels: {},
annotations: {},
deletionTimestamp: false,
},
callback,
};
Expand All @@ -139,6 +153,7 @@ test("should reject when group does not match", () => {
namespaces: [],
labels: {},
annotations: {},
deletionTimestamp: false,
},
callback,
};
Expand All @@ -161,6 +176,7 @@ test("should reject when version does not match", () => {
namespaces: [],
labels: {},
annotations: {},
deletionTimestamp: false,
},
callback,
};
Expand All @@ -179,6 +195,7 @@ test("should allow when group, version, and kind match", () => {
namespaces: [],
labels: {},
annotations: {},
deletionTimestamp: false,
},
callback,
};
Expand All @@ -201,6 +218,7 @@ test("should allow when kind match and others are empty", () => {
namespaces: [],
labels: {},
annotations: {},
deletionTimestamp: false,
},
callback,
};
Expand All @@ -219,6 +237,7 @@ test("should reject when teh capability namespace does not match", () => {
namespaces: [],
labels: {},
annotations: {},
deletionTimestamp: false,
},
callback,
};
Expand All @@ -237,6 +256,7 @@ test("should reject when namespace does not match", () => {
namespaces: ["bleh"],
labels: {},
annotations: {},
deletionTimestamp: false,
},
callback,
};
Expand All @@ -255,6 +275,7 @@ test("should allow when namespace is match", () => {
namespaces: ["default", "unicorn", "things"],
labels: {},
annotations: {},
deletionTimestamp: false,
},
callback,
};
Expand All @@ -275,6 +296,7 @@ test("should reject when label does not match", () => {
foo: "bar",
},
annotations: {},
deletionTimestamp: false,
},
callback,
};
Expand All @@ -290,7 +312,7 @@ test("should allow when label is match", () => {
kind: podKind,
filters: {
name: "",

deletionTimestamp: false,
namespaces: [],
labels: {
foo: "bar",
Expand Down Expand Up @@ -324,6 +346,7 @@ test("should reject when annotation does not match", () => {
annotations: {
foo: "bar",
},
deletionTimestamp: false,
},
callback,
};
Expand All @@ -345,6 +368,7 @@ test("should allow when annotation is match", () => {
foo: "bar",
test: "test1",
},
deletionTimestamp: false,
},
callback,
};
Expand All @@ -368,6 +392,7 @@ test("should use `oldObject` when the operation is `DELETE`", () => {
filters: {
name: "",
namespaces: [],
deletionTimestamp: false,
labels: {
"app.kubernetes.io/name": "cool-name-podinfo",
},
Expand All @@ -382,3 +407,62 @@ test("should use `oldObject` when the operation is `DELETE`", () => {

expect(shouldSkipRequest(binding, pod, [])).toBe(false);
});

test("should skip processing when deletionTimestamp is not present on pod", () => {
const binding = {
model: kind.Pod,
event: Event.Any,
kind: podKind,
filters: {
name: "",
namespaces: [],
labels: {},
annotations: {
foo: "bar",
test: "test1",
},
deletionTimestamp: true,
},
callback,
};

const pod = CreatePod();
pod.object.metadata = pod.object.metadata || {};
pod.object.metadata.annotations = {
foo: "bar",
test: "test1",
test2: "test2",
};

expect(shouldSkipRequest(binding, pod, [])).toBe(true);
});

test("should processing when deletionTimestamp is not present on pod", () => {
const binding = {
model: kind.Pod,
event: Event.Any,
kind: podKind,
filters: {
name: "",
namespaces: [],
labels: {},
annotations: {
foo: "bar",
test: "test1",
},
deletionTimestamp: true,
},
callback,
};

const pod = CreatePod();
pod.object.metadata = pod.object.metadata || {};
pod.object.metadata!.deletionTimestamp = new Date("2021-09-01T00:00:00Z");
pod.object.metadata.annotations = {
foo: "bar",
test: "test1",
test2: "test2",
};

expect(shouldSkipRequest(binding, pod, [])).toBe(false);
});
9 changes: 9 additions & 0 deletions src/lib/filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ export function shouldSkipRequest(binding: Binding, req: AdmissionRequest, capab
const { metadata } = srcObject || {};
const combinedNamespaces = [...namespaces, ...capabilityNamespaces];

// Delete bindings do not work through admission with DeletionTimestamp
if (binding.event.includes(Event.Delete) && binding.filters?.deletionTimestamp) {
return true;
}

// Test for deletionTimestamp
if (binding.filters?.deletionTimestamp && !req.object.metadata?.deletionTimestamp) {
return true;
}
// Test for matching operation
if (!binding.event.includes(operation) && !binding.event.includes(Event.Any)) {
return true;
Expand Down
34 changes: 34 additions & 0 deletions src/lib/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1056,6 +1056,40 @@ describe("filterMatcher", () => {
expect(result).toEqual("Ignoring Watch Callback: Cannot use a namespace filter in a namespace object.");
});

test("return deletionTimestamp error when there is no deletionTimestamp in the object", () => {
const binding = {
filters: { deletionTimestamp: true },
};
const obj = {
metadata: {},
};
const capabilityNamespaces: string[] = [];
const result = filterNoMatchReason(
binding as unknown as Partial<Binding>,
obj as unknown as Partial<KubernetesObject>,
capabilityNamespaces,
);
expect(result).toEqual("Ignoring Watch Callback: Object does not have a deletion timestamp.");
});

test("return no deletionTimestamp error when there is a deletionTimestamp in the object", () => {
const binding = {
filters: { deletionTimestamp: true },
};
const obj = {
metadata: {
deletionTimestamp: "2021-01-01T00:00:00Z",
},
};
const capabilityNamespaces: string[] = [];
const result = filterNoMatchReason(
binding as unknown as Partial<Binding>,
obj as unknown as Partial<KubernetesObject>,
capabilityNamespaces,
);
expect(result).not.toEqual("Ignoring Watch Callback: Object does not have a deletion timestamp.");
});

test("returns label overlap error when there is no overlap between binding and object labels", () => {
const binding = {
filters: { labels: { key: "value" } },
Expand Down
5 changes: 5 additions & 0 deletions src/lib/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ export function filterNoMatchReason(
obj: Partial<KubernetesObject>,
capabilityNamespaces: string[],
): string {
// binding deletionTimestamp filter and object deletionTimestamp dont match
if (binding.filters?.deletionTimestamp && !obj.metadata?.deletionTimestamp) {
return `Ignoring Watch Callback: Object does not have a deletion timestamp.`;
}

// binding kind is namespace with a InNamespace filter
if (binding.kind && binding.kind.kind === "Namespace" && binding.filters && binding.filters.namespaces.length !== 0) {
return `Ignoring Watch Callback: Cannot use a namespace filter in a namespace object.`;
Expand Down
3 changes: 3 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export type Binding = {
namespaces: string[];
labels: Record<string, string>;
annotations: Record<string, string>;
deletionTimestamp: boolean;
};
readonly mutateCallback?: MutateAction<GenericClass, InstanceType<GenericClass>>;
readonly validateCallback?: ValidateAction<GenericClass, InstanceType<GenericClass>>;
Expand Down Expand Up @@ -137,6 +138,8 @@ export type BindingFilter<T extends GenericClass> = CommonActionChain<T> & {
* @param value
*/
WithAnnotation: (key: string, value?: string) => BindingFilter<T>;
/** Only apply the action if the resource has a deletionTimestamp. */
WithDeletionTimestamp: () => BindingFilter<T>;
};

export type BindingWithName<T extends GenericClass> = BindingFilter<T> & {
Expand Down

0 comments on commit a5ae3cc

Please sign in to comment.