Skip to content

Commit 7270e11

Browse files
authored
Merge pull request #222 from projectsyn/inventory-postprocessing-filters
Inventory postprocessing filters
2 parents 06e0833 + 88397eb commit 7270e11

10 files changed

Lines changed: 339 additions & 100 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
1616
components with entries in `applications`.
1717
`classes: [ "components.argocd" ]` becomes `applications: [ "argocd" ]`.
1818

19+
* Add option to define postprocessing filters in the Kapitan inventory ([#222]).
20+
1921
### Fixed
2022

2123
* Replace remaining references to `common.yml` with `commodore.yml` ([#204])
@@ -248,6 +250,7 @@ Initial implementation
248250
[#212]: https://github.com/projectsyn/commodore/pull/212
249251
[#215]: https://github.com/projectsyn/commodore/pull/215
250252
[#217]: https://github.com/projectsyn/commodore/pull/217
253+
[#222]: https://github.com/projectsyn/commodore/pull/222
251254
[#226]: https://github.com/projectsyn/commodore/pull/226
252255
[#227]: https://github.com/projectsyn/commodore/pull/227
253256
[#237]: https://github.com/projectsyn/commodore/pull/237

commodore/postprocess/__init__.py

Lines changed: 166 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,182 @@
1-
from typing import Dict
1+
from pathlib import Path as P
2+
from typing import Any, Callable, ClassVar, Dict, List, Set
3+
from typing_extensions import Protocol
24

35
import click
46

5-
from commodore.component import Component
7+
from commodore.config import Config, Component
68
from commodore.helpers import yaml_load
79

8-
from .jsonnet import run_jsonnet_filter
9-
from .builtin_filters import run_builtin_filter
1010
from .inventory import resolve_inventory_vars, InventoryError
1111

12+
from .jsonnet import run_jsonnet_filter, validate_jsonnet_filter
13+
from .builtin_filters import run_builtin_filter, validate_builtin_filter
1214

13-
def postprocess_components(config, kapitan_inventory, components: Dict[str, Component]):
15+
16+
class FilterFunc(Protocol):
17+
def __call__(
18+
self, inventory: Dict, component: str, filterid: str, path: P, **filterargs: str
19+
):
20+
...
21+
22+
23+
ValidateFunc = Callable[[str, Dict], Dict]
24+
25+
26+
class Filter:
27+
type: str
28+
filter: str
29+
path: P
30+
filterargs: Dict
31+
enabled: bool
32+
33+
# PyLint complains about ClassVar not being subscriptable
34+
# pylint: disable=unsubscriptable-object
35+
_run_handlers: ClassVar[Dict[str, FilterFunc]] = {
36+
"builtin": run_builtin_filter,
37+
"jsonnet": run_jsonnet_filter,
38+
}
39+
# pylint: disable=unsubscriptable-object
40+
_validate_handlers: ClassVar[Dict[str, ValidateFunc]] = {
41+
"builtin": validate_builtin_filter,
42+
"jsonnet": validate_jsonnet_filter,
43+
}
44+
# pylint: disable=unsubscriptable-object
45+
_required_keys: ClassVar[Set[str]] = {"type", "path", "filter"}
46+
47+
def __init__(self, fd: Dict):
48+
"""
49+
Assumes that `fd` has been validated with `_validate_filter`.
50+
"""
51+
self.type = fd["type"]
52+
self.filter = fd["filter"]
53+
self.path = P(fd["path"])
54+
self.enabled = fd.get("enabled", True)
55+
self.filterargs = fd.get("filterargs", {})
56+
self._runner: FilterFunc = self._run_handlers[self.type]
57+
58+
def run(self, inventory: Dict, component: str):
59+
"""
60+
Run the filter.
61+
"""
62+
if not self.enabled:
63+
click.secho(
64+
f" > Skipping disabled filter {self.filter} on path {self.path}"
65+
)
66+
return
67+
68+
self._runner(inventory, component, self.filter, self.path, **self.filterargs)
69+
70+
@classmethod
71+
def validate(cls, cn: str, f: Dict):
72+
"""
73+
Validate filter definition in `f`.
74+
Raises exceptions as appropriate when the definition is invalid.
75+
Returns the definiton if it validates successfully.
76+
"""
77+
if not all(key in f for key in cls._required_keys):
78+
missing_required_keys = cls._required_keys - f.keys()
79+
raise KeyError(f"Filter is missing required key(s) {missing_required_keys}")
80+
81+
if "enabled" in f and not isinstance(f["enabled"], bool):
82+
raise ValueError("Filter key 'enabled' is not a boolean")
83+
84+
typ = f["type"]
85+
if typ not in cls._validate_handlers:
86+
raise ValueError(f"Filter has unknown type {typ}")
87+
88+
# perform type-specific extra validation
89+
cls._validate_handlers[typ](cn, f)
90+
91+
return f
92+
93+
@classmethod
94+
def from_dict(cls, cn: str, f: Dict):
95+
"""
96+
Create Filter object from filter definition dict `f`.
97+
Raises exceptions as appropriate when the definition is invalid.
98+
Returns a Filter object if the passed definition validates successfully.
99+
"""
100+
return Filter(Filter.validate(cn, f))
101+
102+
103+
def _get_inventory_filters(inventory: Dict[str, Any]) -> List[Dict[str, Any]]:
104+
"""
105+
Return list of filters defined in inventory.
106+
107+
Inventory filters are expected to be defined as a list in
108+
`parameters.commodore.postprocess.filters`.
109+
"""
110+
commodore = inventory["parameters"].get("commodore", {})
111+
return commodore.get("postprocess", {}).get("filters", [])
112+
113+
114+
def _get_external_filters(
115+
inventory: Dict[str, Any], c: Component
116+
) -> List[Dict[str, Any]]:
117+
filters_file = c.filters_file
118+
filters = []
119+
if filters_file.is_file():
120+
_filters = yaml_load(filters_file).get("filters", [])
121+
for f in _filters:
122+
# Resolve any inventory references in filter definition
123+
try:
124+
f = resolve_inventory_vars(inventory, f)
125+
except InventoryError as e:
126+
raise click.ClickException(
127+
f"Failed to resolve reclass references for external filter: {e}"
128+
) from e
129+
130+
# external filters without 'type' always have type 'jsonnet'
131+
if "type" not in f:
132+
click.secho(
133+
" > [WARN] component uses untyped external postprocessing filter",
134+
fg="yellow",
135+
)
136+
f["type"] = "jsonnet"
137+
138+
if f["type"] == "jsonnet":
139+
f["path"] = f["output_path"]
140+
del f["output_path"]
141+
f["filter"] = str(P("postprocess") / f["filter"])
142+
filters.append(f)
143+
144+
return filters
145+
146+
147+
def postprocess_components(
148+
config: Config,
149+
kapitan_inventory: Dict[str, Dict[str, Any]],
150+
components: Dict[str, Component],
151+
):
14152
click.secho("Postprocessing...", bold=True)
153+
15154
for cn, c in components.items():
16155
inventory = kapitan_inventory.get(cn)
17156
if not inventory:
18157
click.echo(f" > No target exists for component {cn}, skipping...")
19158
continue
20159

21-
filters_file = c.filters_file
22-
if filters_file.is_file():
160+
# inventory filters
161+
invfilters = _get_inventory_filters(inventory)
162+
163+
# "old", external filters
164+
extfilters = _get_external_filters(inventory, c)
165+
166+
filters: List[Filter] = []
167+
for fd in invfilters + extfilters:
168+
try:
169+
filters.append(Filter.from_dict(cn, fd))
170+
except (KeyError, ValueError) as e:
171+
click.secho(
172+
f" > Skipping filter '{fd['filter']}' with invalid definition {fd}: {e}",
173+
fg="yellow",
174+
)
175+
176+
if len(filters) > 0 and config.debug:
177+
click.echo(f" > {cn}...")
178+
179+
for f in filters:
23180
if config.debug:
24-
click.echo(f" > {cn}...")
25-
filters = yaml_load(filters_file)
26-
for f in filters["filters"]:
27-
# Resolve any inventory references in filter definition
28-
try:
29-
f = resolve_inventory_vars(inventory, f)
30-
except InventoryError as e:
31-
raise click.ClickException(
32-
f"Failed to resolve variables for old-style filter: {e}"
33-
) from e
34-
35-
# filters without 'type' are always 'jsonnet'
36-
if "type" not in f:
37-
click.secho(
38-
" > [WARN] component uses old-style postprocess filters",
39-
fg="yellow",
40-
)
41-
f["type"] = "jsonnet"
42-
# Filters which aren't explicitly disabled are always enabled
43-
if "enabled" in f and not f["enabled"]:
44-
click.secho(
45-
" > Skipping disabled filter"
46-
+ f" {f['filter']} on path {f['path']}"
47-
)
48-
continue
49-
if f["type"] == "jsonnet":
50-
run_jsonnet_filter(inventory, cn, filters_file.parent, f)
51-
elif f["type"] == "builtin":
52-
run_builtin_filter(inventory, cn, f)
53-
else:
54-
click.secho(
55-
f" > [WARN] unknown builtin filter {f['filter']}",
56-
fg="yellow",
57-
)
181+
click.secho(f" > Executing filter '{f.type}:{f.filter}'")
182+
f.run(inventory, cn)

commodore/postprocess/builtin_filters.py

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import json
22

33
from pathlib import Path as P
4+
from typing import Dict
45

56
import _jsonnet
67
import click
@@ -10,15 +11,23 @@
1011
from .jsonnet import jsonnet_runner
1112

1213

13-
def _builtin_filter_helm_namespace(inv, component, path, **kwargs):
14+
def _output_dir(component: str, path):
15+
return P("compiled", component, path)
16+
17+
18+
def _builtin_filter_helm_namespace(inv, component: str, path, **kwargs):
1419
if "namespace" not in kwargs:
1520
raise click.ClickException(
1621
"Builtin filter 'helm_namespace': filter argument 'namespace' is required"
1722
)
1823
create_namespace = kwargs.get("create_namespace", "false")
24+
# Transform create_namespace to string as jsonnet extvars can only be
25+
# strings
26+
if isinstance(create_namespace, bool):
27+
create_namespace = "true" if create_namespace else "false"
1928
exclude_objects = kwargs.get("exclude_objects", [])
2029
exclude_objects = "|".join([json.dumps(e) for e in exclude_objects])
21-
output_dir = P("compiled", component, path)
30+
output_dir = _output_dir(component, path)
2231

2332
# pylint: disable=c-extension-no-member
2433
jsonnet_runner(
@@ -39,9 +48,27 @@ def _builtin_filter_helm_namespace(inv, component, path, **kwargs):
3948
}
4049

4150

42-
def run_builtin_filter(inv, component, f):
43-
fname = f["filter"]
44-
if fname not in _builtin_filters:
45-
click.secho(f" > [ERR ] Unknown builtin filter {fname}", fg="red")
46-
return
47-
_builtin_filters[fname](inv, component, f["path"], **f["filterargs"])
51+
class UnknownBuiltinFilter(ValueError):
52+
def __init__(self, filtername):
53+
super().__init__(f"Unknown builtin filter: {filtername}")
54+
self.filtername = filtername
55+
56+
57+
def run_builtin_filter(
58+
inventory: Dict, component: str, filterid: str, path: P, **filterargs: str
59+
):
60+
if filterid not in _builtin_filters:
61+
raise UnknownBuiltinFilter(filterid)
62+
_builtin_filters[filterid](inventory, component, path, **filterargs)
63+
64+
65+
def validate_builtin_filter(cn: str, fd: Dict):
66+
if fd["filter"] not in _builtin_filters:
67+
raise UnknownBuiltinFilter(fd["filter"])
68+
69+
if "filterargs" not in fd:
70+
raise KeyError("Builtin filter is missing required key 'filterargs'")
71+
72+
fpath = _output_dir(cn, fd["path"])
73+
if not fpath.exists():
74+
raise ValueError("Builtin filter called on path which doesn't exist")

0 commit comments

Comments
 (0)