Skip to content
This repository was archived by the owner on Oct 3, 2020. It is now read-only.

Commit 6955a8d

Browse files
authored
Support custom hooks for resource context (#64)
* add option for custom hook --resource-context-hook * provide example hook * no need to pass default args * cache objects from Kubernetes API (pods for PVC) * test get_hook_function
1 parent bb28ecd commit 6955a8d

File tree

8 files changed

+132
-12
lines changed

8 files changed

+132
-12
lines changed

README.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,8 @@ Available command line options:
111111
Optional: filename pointing to a YAML file with a list of rules to apply TTL values to arbitrary Kubernetes objects, e.g. to delete all deployments without a certain label automatically after N days. See Rules File configuration section below.
112112
``--deployment-time-annotation``
113113
Optional: name of the annotation that would be used instead of the creation timestamp of the resource. This option should be used if you want the resources to not be cleaned up if they've been recently redeployed, and your deployment tooling can set this annotation.
114+
``--resource-context-hook``
115+
Optional: string pointing to a Python function to populate the ``_context`` object with additional information, e.g. by calling external services. Built-in example to set ``_context.random_dice`` to a random dice value (1-6): ``--resource-context-hook=kube_janitor.example_hooks.random_dice``.
114116

115117
Example flags:
116118

kube_janitor/cmd.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,21 @@
11
import argparse
2+
import importlib
23
import os
4+
from typing import Callable
35

46
DEFAULT_EXCLUDE_RESOURCES = "events,controllerrevisions"
57
DEFAULT_EXCLUDE_NAMESPACES = "kube-system"
68

79

10+
def get_hook_function(value: str) -> Callable:
11+
module_name, attr_path = value.rsplit(".", 1)
12+
module = importlib.import_module(module_name)
13+
function = getattr(module, attr_path)
14+
if not callable(function):
15+
raise ValueError(f"Not a callable function: {value}")
16+
return function
17+
18+
819
def get_parser():
920
parser = argparse.ArgumentParser()
1021
parser.add_argument(
@@ -55,4 +66,9 @@ def get_parser():
5566
"--deployment-time-annotation",
5667
help="Annotation that contains a resource's last deployment time, overrides creationTime",
5768
)
69+
parser.add_argument(
70+
"--resource-context-hook",
71+
type=get_hook_function,
72+
help="Optional hook to extend the '_context' object with custom information, e.g. to base decisions on external systems",
73+
)
5874
return parser

kube_janitor/example_hooks.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""
2+
Example resource context hooks for Kubernetes Janitor.
3+
4+
Usage: --resource-context-hook=kube_janitor.example_hooks.random_dice
5+
"""
6+
import logging
7+
import random
8+
from typing import Any
9+
from typing import Dict
10+
11+
from pykube.objects import APIObject
12+
13+
logger = logging.getLogger(__name__)
14+
15+
CACHE_KEY = "random_dice"
16+
17+
18+
def random_dice(resource: APIObject, cache: Dict[str, Any]) -> Dict[str, Any]:
19+
"""Built-in example resource context hook to set ``_context.random_dice`` to a random dice value (1-6)."""
20+
21+
# re-use any value from the cache to have only one dice roll per janitor run
22+
dice_value = cache.get(CACHE_KEY)
23+
24+
if dice_value is None:
25+
# roll the dice
26+
dice_value = random.randint(1, 6)
27+
logger.debug(f"The random dice value is {dice_value}!")
28+
cache[CACHE_KEY] = dice_value
29+
30+
return {"random_dice": dice_value}

kube_janitor/janitor.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import datetime
22
import logging
33
from collections import Counter
4+
from typing import Any
5+
from typing import Callable
6+
from typing import Dict
47
from typing import Optional
58

69
import pykube
710
from pykube import Event
811
from pykube import Namespace
12+
from pykube.objects import APIObject
913

1014
from .helper import format_duration
1115
from .helper import parse_expiry
@@ -168,16 +172,18 @@ def handle_resource_on_ttl(
168172
resource,
169173
rules,
170174
delete_notification: int,
171-
deployment_time_annotation: Optional[str],
172-
dry_run: bool,
175+
deployment_time_annotation: Optional[str] = None,
176+
resource_context_hook: Optional[Callable[[APIObject, dict], Dict[str, Any]]] = None,
177+
cache: Dict[str, Any] = None,
178+
dry_run: bool = False,
173179
):
174180
counter = {"resources-processed": 1}
175181

176182
ttl = resource.annotations.get(TTL_ANNOTATION)
177183
if ttl:
178184
reason = f"annotation {TTL_ANNOTATION} is set"
179185
else:
180-
context = get_resource_context(resource)
186+
context = get_resource_context(resource, resource_context_hook, cache)
181187
for rule in rules:
182188
if rule.matches(resource, context):
183189
logger.debug(
@@ -272,11 +278,13 @@ def clean_up(
272278
exclude_namespaces: frozenset,
273279
rules: list,
274280
delete_notification: int,
275-
deployment_time_annotation: Optional[str],
276-
dry_run: bool,
281+
deployment_time_annotation: Optional[str] = None,
282+
resource_context_hook: Optional[Callable[[APIObject, dict], Dict[str, Any]]] = None,
283+
dry_run: bool = False,
277284
):
278285

279286
counter: Counter = Counter()
287+
cache: Dict[str, Any] = {}
280288

281289
for namespace in Namespace.objects(api):
282290
if matches_resource_filter(
@@ -292,6 +300,8 @@ def clean_up(
292300
rules,
293301
delete_notification,
294302
deployment_time_annotation,
303+
resource_context_hook,
304+
cache,
295305
dry_run,
296306
)
297307
)
@@ -340,6 +350,8 @@ def clean_up(
340350
rules,
341351
delete_notification,
342352
deployment_time_annotation,
353+
resource_context_hook,
354+
cache,
343355
dry_run,
344356
)
345357
)

kube_janitor/main.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
#!/usr/bin/env python3
22
import logging
33
import time
4+
from typing import Callable
5+
from typing import Optional
46

57
from kube_janitor import __version__
68
from kube_janitor import cmd
@@ -43,6 +45,7 @@ def main(args=None):
4345
args.interval,
4446
args.delete_notification,
4547
args.deployment_time_annotation,
48+
args.resource_context_hook,
4649
args.dry_run,
4750
)
4851

@@ -56,8 +59,9 @@ def run_loop(
5659
rules,
5760
interval,
5861
delete_notification,
59-
deployment_time_annotation,
60-
dry_run,
62+
deployment_time_annotation: Optional[str],
63+
resource_context_hook: Optional[Callable],
64+
dry_run: bool,
6165
):
6266
handler = shutdown.GracefulShutdown()
6367
while True:
@@ -72,6 +76,7 @@ def run_loop(
7276
rules=rules,
7377
delete_notification=delete_notification,
7478
deployment_time_annotation=deployment_time_annotation,
79+
resource_context_hook=resource_context_hook,
7580
dry_run=dry_run,
7681
)
7782
except Exception as e:

kube_janitor/resource_context.py

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import logging
22
import re
33
from typing import Any
4+
from typing import Callable
45
from typing import Dict
6+
from typing import Optional
57

8+
from pykube import HTTPClient
69
from pykube.objects import APIObject
710
from pykube.objects import NamespacedAPIObject
811
from pykube.objects import Pod
@@ -11,13 +14,28 @@
1114
logger = logging.getLogger(__name__)
1215

1316

14-
def get_persistent_volume_claim_context(pvc: NamespacedAPIObject):
17+
def get_objects_in_namespace(
18+
clazz, api: HTTPClient, namespace: str, cache: Dict[str, Any]
19+
):
20+
"""Get (cached) objects from the Kubernetes API."""
21+
cache_key = f"{namespace}/{clazz.endpoint}"
22+
objects = cache.get(cache_key)
23+
if objects is None:
24+
objects = list(clazz.objects(api, namespace=namespace))
25+
cache[cache_key] = objects
26+
27+
return objects
28+
29+
30+
def get_persistent_volume_claim_context(
31+
pvc: NamespacedAPIObject, cache: Dict[str, Any]
32+
):
1533
"""Get context for PersistentVolumeClaim: whether it's mounted by a Pod and whether it's referenced by a StatefulSet."""
1634
pvc_is_mounted = False
1735
pvc_is_referenced = False
1836

1937
# find out whether a Pod mounts the PVC
20-
for pod in Pod.objects(pvc.api, namespace=pvc.namespace):
38+
for pod in get_objects_in_namespace(Pod, pvc.api, pvc.namespace, cache):
2139
for volume in pod.obj.get("spec", {}).get("volumes", []):
2240
if "persistentVolumeClaim" in volume:
2341
if volume["persistentVolumeClaim"].get("claimName") == pvc.name:
@@ -28,7 +46,7 @@ def get_persistent_volume_claim_context(pvc: NamespacedAPIObject):
2846
break
2947

3048
# find out whether the PVC is still referenced somewhere
31-
for sts in StatefulSet.objects(pvc.api, namespace=pvc.namespace):
49+
for sts in get_objects_in_namespace(StatefulSet, pvc.api, pvc.namespace, cache):
3250
# see https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/
3351
for claim_template in sts.obj.get("spec", {}).get("volumeClaimTemplates", []):
3452
claim_prefix = claim_template.get("metadata", {}).get("name")
@@ -47,12 +65,27 @@ def get_persistent_volume_claim_context(pvc: NamespacedAPIObject):
4765
}
4866

4967

50-
def get_resource_context(resource: APIObject):
68+
def get_resource_context(
69+
resource: APIObject,
70+
hook: Optional[Callable[[APIObject, dict], Dict[str, Any]]] = None,
71+
cache: Optional[Dict[str, Any]] = None,
72+
):
5173
"""Get additional context information for a single resource, e.g. whether a PVC is mounted/used or not."""
5274

5375
context: Dict[str, Any] = {}
5476

77+
if cache is None:
78+
cache = {}
79+
5580
if resource.kind == "PersistentVolumeClaim":
56-
context.update(get_persistent_volume_claim_context(resource))
81+
context.update(get_persistent_volume_claim_context(resource, cache))
82+
83+
if hook:
84+
try:
85+
context.update(hook(resource, cache))
86+
except Exception as e:
87+
logger.exception(
88+
f"Failed populating _context from resource context hook: {e}"
89+
)
5790

5891
return context

tests/test_cmd.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
1+
import kube_janitor.example_hooks
2+
from kube_janitor.cmd import get_hook_function
13
from kube_janitor.cmd import get_parser
24

35

46
def test_parse_args():
57
parser = get_parser()
68
parser.parse_args(["--dry-run", "--rules-file=/config/rules.yaml"])
9+
10+
11+
def test_get_example_hook_function():
12+
func = get_hook_function("kube_janitor.example_hooks.random_dice")
13+
assert func == kube_janitor.example_hooks.random_dice

tests/test_resource_context.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
from unittest.mock import MagicMock
22

3+
from pykube.objects import Namespace
34
from pykube.objects import PersistentVolumeClaim
45

6+
import kube_janitor.example_hooks
57
from kube_janitor.resource_context import get_resource_context
68

79

@@ -83,3 +85,16 @@ def get(**kwargs):
8385

8486
context = get_resource_context(pvc)
8587
assert not context["pvc_is_not_referenced"]
88+
89+
90+
def test_example_hook():
91+
namespace = Namespace(None, {"metadata": {"name": "my-ns"}})
92+
hook = kube_janitor.example_hooks.random_dice
93+
cache = {}
94+
context = get_resource_context(namespace, hook, cache)
95+
value = context["random_dice"]
96+
assert 1 <= value <= 6
97+
98+
# check that cache is used
99+
new_context = get_resource_context(namespace, hook, cache)
100+
assert new_context["random_dice"] == value

0 commit comments

Comments
 (0)