Skip to content

Commit eff62f7

Browse files
authored
Merge pull request #22 from Kpler/chore/add-jsonschema-validation
Chore/add jsonschema validation
2 parents fe98b88 + 63cf0a0 commit eff62f7

4 files changed

Lines changed: 223 additions & 0 deletions

File tree

.pre-commit-hooks.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,11 @@
2626
entry: ./fastapi-generate-openapi-specification.sh
2727
stages: [commit]
2828
pass_filenames: false
29+
30+
- id: gitops-values-validation
31+
name: GitOps values JSON schema validation
32+
language: python
33+
entry: python -m kp_pre_commit_hooks.gitops-values-validation
34+
stages: [commit]
35+
always_run: true
36+
pass_filenames: false

kp_pre_commit_hooks/__init__.py

Whitespace-only changes.
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
#!/usr/bin/env python3
2+
3+
import json
4+
import sys
5+
from itertools import chain
6+
from pathlib import Path
7+
8+
import requests
9+
import urllib3.exceptions
10+
from jsonschema import Draft7Validator
11+
from jsonschema_specifications import REGISTRY
12+
from referencing import Registry, Resource
13+
from ruamel.yaml import YAML
14+
15+
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
16+
17+
SCHEMA_BASE_URL = "https://kp-helmchart-stable-shared-main.s3.eu-west-1.amazonaws.com/schema/platform-managed-chart"
18+
GITOPS_DIR = Path("gitops")
19+
20+
yaml = YAML()
21+
22+
23+
def retrieve_schema_via_request(uri):
24+
response = requests.get(uri)
25+
return Resource.from_contents(response.json())
26+
27+
28+
SCHEMA_REGISTRY = REGISTRY.combine(Registry(retrieve=retrieve_schema_via_request))
29+
30+
31+
def download_json_schema_for_chart_version(version):
32+
schema_url = f"{SCHEMA_BASE_URL}/v{version}/schema-platform-managed-chart-strict.json"
33+
response = requests.get(schema_url, timeout=10, verify=False)
34+
35+
if response.status_code != 200:
36+
print(
37+
f"Error fetching schema url {schema_url}. HTTP Status Code: {response.status_code}\nPlease enable VPN first."
38+
)
39+
sys.exit(4)
40+
41+
try:
42+
schema_json = response.json()
43+
return schema_json
44+
except json.JSONDecodeError:
45+
print(f"Error decoding JSON. Response content:\n{response.text}")
46+
sys.exit(4)
47+
48+
49+
def verify_values_files_schema_version(version, directory=Path(".")):
50+
for filename in directory.glob("values*.yaml"):
51+
value_file = filename.read_text(encoding="utf8")
52+
for line in value_file.splitlines():
53+
if line.startswith("# yaml-language-server: $schema="):
54+
schema_version = line.split("=")[1].split("/")[-2].replace("v", "")
55+
if schema_version != version:
56+
print(f"ERROR: validation failure for {directory}/{filename}")
57+
print(
58+
f"reason: JSON schema version in '{line}' does not match version {version} in Chart.yaml"
59+
)
60+
# Let's automatically fix the version in the file on the way
61+
filename.write_text(
62+
value_file.replace(line, line.replace(schema_version, version))
63+
)
64+
return False
65+
return True
66+
67+
68+
def delete_error_files(directory=Path(".")):
69+
for filename in directory.glob("error-merged-values-*.yaml"):
70+
try:
71+
filename.unlink()
72+
except Exception as err:
73+
print(f"Error deleting {filename}. Reason: {err}")
74+
75+
76+
def deep_merge(source, destination):
77+
for key, value in source.items():
78+
if isinstance(value, dict):
79+
# Get node or create one
80+
node = destination.setdefault(key, {})
81+
deep_merge(value, node)
82+
else:
83+
destination[key] = value
84+
85+
return destination
86+
87+
88+
def merge_service_values_files(service_path, instance_file):
89+
"""Merge values.yaml, values-env.yaml and values-env-instance.yaml files."""
90+
merged_data = {}
91+
92+
# Base file
93+
base_file = (service_path / "values.yaml").read_text(encoding="utf8")
94+
merged_data.update(yaml.load(base_file))
95+
96+
# Environment file
97+
env = "dev" if "dev" in instance_file else "prod"
98+
env_file_path = service_path / f"values-{env}.yaml"
99+
if env_file_path.exists():
100+
env_file = env_file_path.read_text(encoding="utf8")
101+
env_data = yaml.load(env_file)
102+
deep_merge(env_data, merged_data)
103+
104+
# Instance file
105+
instance_f = (service_path / instance_file).read_text(encoding="utf8")
106+
instance_data = yaml.load(instance_f)
107+
deep_merge(instance_data, merged_data)
108+
109+
return merged_data
110+
111+
112+
def find_full_error_path(error):
113+
if error.parent:
114+
return find_full_error_path(error.parent) + error.path
115+
else:
116+
return error.path
117+
118+
119+
def main():
120+
if not GITOPS_DIR.exists():
121+
print(f"{GITOPS_DIR} directory is missing, exiting...")
122+
sys.exit(0)
123+
124+
error_found = False
125+
126+
# Iterate over direct subdirectories inside GITOPS_DIR
127+
for service_path in GITOPS_DIR.glob("*/*"):
128+
chart_file = service_path / "Chart.yaml"
129+
value_file = service_path / "values.yaml"
130+
if not (chart_file.is_file() and value_file.is_file()):
131+
print(f"Chart.yaml or values.yaml file is missing in {service_path}, skipping...")
132+
continue
133+
134+
chart_data = yaml.load(chart_file.read_text(encoding="utf8"))
135+
chart_version = next(
136+
(
137+
dep["version"]
138+
for dep in chart_data.get("dependencies", [])
139+
if dep["name"] == "platform-managed-chart"
140+
),
141+
None,
142+
)
143+
144+
if not chart_version:
145+
print(
146+
f"Chart.yaml {chart_file} is missing platform-managed-chart dependency, skipping..."
147+
)
148+
continue
149+
150+
if not verify_values_files_schema_version(chart_version, service_path):
151+
error_found = True
152+
continue
153+
154+
schema_data = download_json_schema_for_chart_version(chart_version)
155+
if not schema_data:
156+
print(f"JSON schema for version {chart_version} is not supported, skipping...")
157+
continue
158+
159+
for instance_file_path in chain(
160+
service_path.glob("values-dev-*.yaml"),
161+
service_path.glob("values-prod-*.yaml"),
162+
):
163+
instance_file = instance_file_path.name
164+
merged_values = merge_service_values_files(service_path, instance_file)
165+
validator = Draft7Validator(schema_data, registry=SCHEMA_REGISTRY) # type: ignore
166+
errors = list(validator.iter_errors(merged_values))
167+
168+
if errors:
169+
error_found = True
170+
base_file = "-".join(instance_file.split("-")[:2]) + ".yaml"
171+
print(
172+
f"\nERROR: Validation errors for {service_path} in {instance_file}, {base_file} or values.yaml:"
173+
)
174+
for error in errors:
175+
error_location = "/".join(find_full_error_path(error)) or "the root"
176+
print(f" - {error.message} at {error_location}")
177+
178+
output_file = service_path / f"error-merged-{instance_file}"
179+
with output_file.open("w", encoding="utf8") as out:
180+
out.write(
181+
f"# yaml-language-server: $schema={SCHEMA_BASE_URL}/v${chart_version}/schema-platform-managed-chart-strict.json\n"
182+
)
183+
yaml.dump(merged_values, out)
184+
185+
delete_error_files(service_path)
186+
187+
if error_found:
188+
sys.exit(1)
189+
190+
191+
if __name__ == "__main__":
192+
main()

pyproject.toml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
[tool.poetry]
2+
name = "kp-pre-commit-hooks"
3+
version = "0.1.0"
4+
description = "Set of Kpler specific pre-commit hooks"
5+
packages = [{include = "kp_pre_commit_hooks"}]
6+
authors = ["Kpler Engineering <team-engineering@kpler.com>"]
7+
8+
[tool.poetry.dependencies]
9+
python = "^3.9"
10+
jsonschema = "^4.19.0"
11+
requests = "^2.31.0"
12+
ruamel-yaml = "^0.17.32"
13+
urllib3 = "^1.26.15"
14+
15+
[tool.poetry.group.dev.dependencies]
16+
black = {version = "^22.10.0", allow-prereleases = true}
17+
18+
[build-system]
19+
requires = ["poetry-core"]
20+
build-backend = "poetry.core.masonry.api"
21+
22+
[tool.black]
23+
line-length = 100

0 commit comments

Comments
 (0)