Skip to content

Commit 39d42fb

Browse files
authored
[Feature] Add Kubernetes manifest validation in pre-commit. (#2380)
1 parent d025792 commit 39d42fb

File tree

6 files changed

+240
-3
lines changed

6 files changed

+240
-3
lines changed

.github/workflows/test-job.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ jobs:
1717
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s v1.60.3
1818
mv ./bin/golangci-lint /usr/local/bin/golangci-lint
1919
shell: bash
20+
21+
- name: Install kubeconform
22+
run: |
23+
curl -L https://github.com/yannh/kubeconform/releases/download/v0.6.7/kubeconform-linux-amd64.tar.gz -o kubeconform.tar.gz
24+
tar -xzf kubeconform.tar.gz
25+
mv kubeconform /usr/local/bin/
26+
2027
- uses: actions/checkout@v3
2128
- uses: actions/setup-python@v3
2229
- uses: pre-commit/[email protected]

.pre-commit-config.yaml

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ repos:
3131
hooks:
3232
- id: check-golangci-lint-version
3333
name: golangci-lint version check
34-
entry: bash -c 'version="1.60.3"; [ "$(golangci-lint --version | grep -oP "(?<=version )[\d\.]+")" = "$version" ] || echo "golangci-lint version is not $version"'
34+
entry: bash -c 'version="1.60.3"; [ "$(golangci-lint --version | grep -oP "(?<=version )[\d\.]+")" = "$version" ] || { echo "golangci-lint version is not $version"; exit 1; }'
3535
language: system
3636
always_run: true
3737
fail_fast: true
@@ -56,3 +56,21 @@ repos:
5656
language: golang
5757
require_serial: true
5858
files: ^kubectl-plugin/
59+
60+
- repo: local
61+
hooks:
62+
- id: check-kubeconform-version
63+
name: kubeconform version check
64+
entry: bash -c 'version="0.6.7"; [ "$(kubeconform -v | grep -oP "(?<=v)[\d\.]+")" = "$version" ] || { echo "kubeconform version is not $version"; exit 1; }'
65+
language: system
66+
always_run: true
67+
fail_fast: true
68+
pass_filenames: false
69+
70+
- repo: local
71+
hooks:
72+
- id: validate-helm-charts
73+
name: validate helm charts with kubeconform
74+
entry: bash scripts/validate-helm.sh
75+
language: system
76+
pass_filenames: false

helm-chart/ray-cluster/templates/raycluster-cluster.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,9 @@ spec:
167167
{{- if $values.envFrom }}
168168
envFrom: {{- toYaml $values.envFrom | nindent 14 }}
169169
{{- end }}
170+
{{- if $values.ports }}
170171
ports: {{- toYaml $values.ports | nindent 14}}
172+
{{- end }}
171173
{{- if $values.lifecycle }}
172174
lifecycle:
173175
{{- toYaml $values.lifecycle | nindent 14 }}
@@ -262,7 +264,9 @@ spec:
262264
{{- with .Values.worker.envFrom }}
263265
envFrom: {{- toYaml . | nindent 14}}
264266
{{- end }}
267+
{{- if .Values.worker.ports }}
265268
ports: {{- toYaml .Values.worker.ports | nindent 14}}
269+
{{- end }}
266270
{{- if .Values.worker.lifecycle }}
267271
lifecycle:
268272
{{- toYaml .Values.worker.lifecycle | nindent 14 }}

ray-operator/DEVELOPMENT.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -203,8 +203,9 @@ helm uninstall kuberay-operator; helm install kuberay-operator --set image.repos
203203
## pre-commit hooks
204204

205205
1. Install [golangci-lint](https://github.com/golangci/golangci-lint/releases).
206-
2. Install [pre-commit](https://pre-commit.com/).
207-
3. Run `pre-commit install` to install the pre-commit hooks.
206+
2. Install [kubeconform](https://github.com/yannh/kubeconform/releases).
207+
3. Install [pre-commit](https://pre-commit.com/).
208+
4. Run `pre-commit install` to install the pre-commit hooks.
208209
209210
## CI/CD
210211

scripts/openapi2jsonschema.py

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
#!/usr/bin/env python3
2+
3+
# This script is directly derived from:
4+
# Source: https://github.com/yannh/kubeconform/blob/master/scripts/openapi2jsonschema.py
5+
6+
# Derived from https://github.com/instrumenta/openapi2jsonschema
7+
import yaml
8+
import json
9+
import sys
10+
import os
11+
import urllib.request
12+
if 'DISABLE_SSL_CERT_VALIDATION' in os.environ:
13+
import ssl
14+
ssl._create_default_https_context = ssl._create_unverified_context
15+
16+
def test_additional_properties():
17+
for test in iter([{
18+
"input": {"something": {"properties": {}}},
19+
"expect": {'something': {'properties': {}, "additionalProperties": False}}
20+
},{
21+
"input": {"something": {"somethingelse": {}}},
22+
"expect": {'something': {'somethingelse': {}}}
23+
}]):
24+
assert additional_properties(test["input"]) == test["expect"]
25+
26+
def additional_properties(data, skip=False):
27+
"This recreates the behaviour of kubectl at https://github.com/kubernetes/kubernetes/blob/225b9119d6a8f03fcbe3cc3d590c261965d928d0/pkg/kubectl/validation/schema.go#L312"
28+
if isinstance(data, dict):
29+
if "properties" in data and not skip:
30+
if "additionalProperties" not in data:
31+
data["additionalProperties"] = False
32+
for _, v in data.items():
33+
additional_properties(v)
34+
return data
35+
36+
def test_replace_int_or_string():
37+
for test in iter([{
38+
"input": {"something": {"format": "int-or-string"}},
39+
"expect": {'something': {'oneOf': [{'type': 'string'}, {'type': 'integer'}]}}
40+
},{
41+
"input": {"something": {"format": "string"}},
42+
"expect": {"something": {"format": "string"}},
43+
}]):
44+
assert replace_int_or_string(test["input"]) == test["expect"]
45+
46+
def replace_int_or_string(data):
47+
new = {}
48+
try:
49+
for k, v in iter(data.items()):
50+
new_v = v
51+
if isinstance(v, dict):
52+
if "format" in v and v["format"] == "int-or-string":
53+
new_v = {"oneOf": [{"type": "string"}, {"type": "integer"}]}
54+
else:
55+
new_v = replace_int_or_string(v)
56+
elif isinstance(v, list):
57+
new_v = list()
58+
for x in v:
59+
new_v.append(replace_int_or_string(x))
60+
else:
61+
new_v = v
62+
new[k] = new_v
63+
return new
64+
except AttributeError:
65+
return data
66+
67+
def allow_null_optional_fields(data, parent=None, grand_parent=None, key=None):
68+
new = {}
69+
try:
70+
for k, v in iter(data.items()):
71+
new_v = v
72+
if isinstance(v, dict):
73+
new_v = allow_null_optional_fields(v, data, parent, k)
74+
elif isinstance(v, list):
75+
new_v = list()
76+
for x in v:
77+
new_v.append(allow_null_optional_fields(x, v, parent, k))
78+
elif isinstance(v, str):
79+
is_non_null_type = k == "type" and v != "null"
80+
has_required_fields = grand_parent and "required" in grand_parent
81+
if is_non_null_type and not has_required_fields:
82+
new_v = [v, "null"]
83+
new[k] = new_v
84+
return new
85+
except AttributeError:
86+
return data
87+
88+
89+
def append_no_duplicates(obj, key, value):
90+
"""
91+
Given a dictionary, lookup the given key, if it doesn't exist create a new array.
92+
Then check if the given value already exists in the array, if it doesn't add it.
93+
"""
94+
if key not in obj:
95+
obj[key] = []
96+
if value not in obj[key]:
97+
obj[key].append(value)
98+
99+
100+
def write_schema_file(schema, filename):
101+
schemaJSON = ""
102+
103+
schema = additional_properties(schema, skip=not os.getenv("DENY_ROOT_ADDITIONAL_PROPERTIES"))
104+
schema = replace_int_or_string(schema)
105+
schemaJSON = json.dumps(schema, indent=2)
106+
107+
# Dealing with user input here..
108+
filename = os.path.basename(filename)
109+
f = open(filename, "w")
110+
print(schemaJSON, file=f)
111+
f.close()
112+
print("JSON schema written to {filename}".format(filename=filename))
113+
114+
115+
def construct_value(load, node):
116+
# Handle nodes that start with '='
117+
# See https://github.com/yaml/pyyaml/issues/89
118+
if not isinstance(node, yaml.ScalarNode):
119+
raise yaml.constructor.ConstructorError(
120+
"while constructing a value",
121+
node.start_mark,
122+
"expected a scalar, but found %s" % node.id, node.start_mark
123+
)
124+
yield str(node.value)
125+
126+
127+
if __name__ == "__main__":
128+
if len(sys.argv) < 2:
129+
print('Missing FILE parameter.\nUsage: %s [FILE]' % sys.argv[0])
130+
exit(1)
131+
132+
for crdFile in sys.argv[1:]:
133+
if crdFile.startswith("http"):
134+
f = urllib.request.urlopen(crdFile)
135+
else:
136+
f = open(crdFile)
137+
with f:
138+
defs = []
139+
yaml.SafeLoader.add_constructor(u'tag:yaml.org,2002:value', construct_value)
140+
for y in yaml.load_all(f, Loader=yaml.SafeLoader):
141+
if y is None:
142+
continue
143+
if "items" in y:
144+
defs.extend(y["items"])
145+
if "kind" not in y:
146+
continue
147+
if y["kind"] != "CustomResourceDefinition":
148+
continue
149+
else:
150+
defs.append(y)
151+
152+
for y in defs:
153+
filename_format = os.getenv("FILENAME_FORMAT", "{kind}_{version}")
154+
filename = ""
155+
if "spec" in y and "versions" in y["spec"] and y["spec"]["versions"]:
156+
for version in y["spec"]["versions"]:
157+
if "schema" in version and "openAPIV3Schema" in version["schema"]:
158+
filename = filename_format.format(
159+
kind=y["spec"]["names"]["kind"],
160+
group=y["spec"]["group"].split(".")[0],
161+
fullgroup=y["spec"]["group"],
162+
version=version["name"],
163+
).lower() + ".json"
164+
165+
schema = version["schema"]["openAPIV3Schema"]
166+
write_schema_file(schema, filename)
167+
elif "validation" in y["spec"] and "openAPIV3Schema" in y["spec"]["validation"]:
168+
filename = filename_format.format(
169+
kind=y["spec"]["names"]["kind"],
170+
group=y["spec"]["group"].split(".")[0],
171+
fullgroup=y["spec"]["group"],
172+
version=version["name"],
173+
).lower() + ".json"
174+
175+
schema = y["spec"]["validation"]["openAPIV3Schema"]
176+
write_schema_file(schema, filename)
177+
elif "spec" in y and "validation" in y["spec"] and "openAPIV3Schema" in y["spec"]["validation"]:
178+
filename = filename_format.format(
179+
kind=y["spec"]["names"]["kind"],
180+
group=y["spec"]["group"].split(".")[0],
181+
fullgroup=y["spec"]["group"],
182+
version=y["spec"]["version"],
183+
).lower() + ".json"
184+
185+
schema = y["spec"]["validation"]["openAPIV3Schema"]
186+
write_schema_file(schema, filename)
187+
188+
exit(0)

scripts/validate-helm.sh

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
#!/bin/bash
2+
set -euo pipefail
3+
export KUBERAY_HOME=$(git rev-parse --show-toplevel)
4+
SCRIPT_PATH="${KUBERAY_HOME}/scripts/openapi2jsonschema.py"
5+
RAYCLUSTER_CRD_PATH="$KUBERAY_HOME/ray-operator/config/crd/bases/ray.io_rayclusters.yaml"
6+
tmp=$(mktemp -d)
7+
trap 'rm -rf "$tmp"' EXIT
8+
9+
# Convert CRD YAML to JSON Schema
10+
pushd "${tmp}" > /dev/null
11+
"$SCRIPT_PATH" "$RAYCLUSTER_CRD_PATH"
12+
popd > /dev/null
13+
RAYCLUSTER_CRD_SCHEMA="${tmp}/raycluster_v1.json"
14+
15+
# Validate Helm charts with kubeconform
16+
echo "Validating Helm Charts with kubeconform..."
17+
helm template "$KUBERAY_HOME/helm-chart/kuberay-apiserver" | kubeconform --summary -schema-location default
18+
helm template "$KUBERAY_HOME/helm-chart/kuberay-operator" | kubeconform --summary -schema-location default
19+
helm template "$KUBERAY_HOME/helm-chart/ray-cluster" | kubeconform --summary -schema-location default -schema-location "$RAYCLUSTER_CRD_SCHEMA"

0 commit comments

Comments
 (0)