Skip to content

Commit c8df431

Browse files
rnetsermyakove
andauthored
api version - use class attr and extract api group from api version (#1935)
* resolve conflicts * fix comment * Use split, not re. add some tests * Use split, not re. add some tests * ENable debug to file, remove verbos * Added support for running script from debug file and improved interactive mode functionality * Update .gitignore to ignore class generator script debug files * Update README.md to reflect latest oc/kubectl version requirement, add open issue instractions * feat: add debug output file path to resource class generator --------- Co-authored-by: Meni Yakove <myakove@gmail.com>
1 parent ccc9691 commit c8df431

File tree

10 files changed

+3061
-38
lines changed

10 files changed

+3061
-38
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,3 +123,6 @@ local-cluster/_hco/
123123
node_modules/
124124
package.json
125125
package-lock.json
126+
127+
# class generator script
128+
*-debug.json

scripts/resource/README.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
## prerequisites
44

55
- [poetry](https://python-poetry.org/)
6-
- [oc](https://mirror.openshift.com/pub/openshift-v4/x86_64/clients/ocp/stable/) or [kubectl](https://kubernetes.io/docs/tasks/tools/)
6+
- [oc](https://mirror.openshift.com/pub/openshift-v4/x86_64/clients/ocp/stable/) or [kubectl](https://kubernetes.io/docs/tasks/tools/) (latest version)
77
- Kubernetes/Openshift cluster
88

99
## Usage
@@ -16,6 +16,8 @@ poetry install
1616

1717
###### Call the script
1818

19+
- Running in normal mode with `--kind` and `--api-link` flags:
20+
1921
```bash
2022
poetry run python scripts/resource/class_generator.py --kind <kind> --api-link <link to resource API or DOC>
2123

@@ -26,3 +28,18 @@ Run in interactive mode:
2628
```bash
2729
poetry run python scripts/resource/class_generator.py --interactive
2830
```
31+
32+
## Reporting an issue
33+
34+
- Running with debug mode and `--debug` flag:
35+
36+
```bash
37+
poetry run python scripts/resource/class_generator.py --kind <kind> --api-link <link to resource API or DOC> --debug
38+
```
39+
40+
`<kind>-debug.json` will be located under `scripts/resource/debug`
41+
Issue should include:
42+
43+
- The script executed command
44+
- debug file from the above command
45+
- oc/kubectl version

scripts/resource/class_generator.py

Lines changed: 153 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from __future__ import annotations
2+
import json
23
import shlex
34
import os
45

@@ -50,11 +51,25 @@ def check_cluster_available() -> bool:
5051
return run_command(command=shlex.split(f"{_exec} version"))[0]
5152

5253

53-
def get_kind_data(kind: str) -> Dict[str, Any]:
54+
def write_to_file(kind: str, data: Dict[str, Any], output_debug_file_path: str) -> None:
55+
content = {}
56+
if os.path.isfile(output_debug_file_path):
57+
with open(output_debug_file_path) as fd:
58+
content = json.load(fd)
59+
60+
content.update(data)
61+
with open(output_debug_file_path, "w") as fd:
62+
json.dump(content, fd, indent=4)
63+
64+
65+
def get_kind_data(kind: str, debug: bool = False, output_debug_file_path: str = "") -> Dict[str, Any]:
5466
"""
5567
Get oc/kubectl explain output for given kind and if kind is namespaced
5668
"""
5769
_, explain_out, _ = run_command(command=shlex.split(f"oc explain {kind} --recursive"))
70+
if debug:
71+
write_to_file(kind=kind, data={"explain": explain_out}, output_debug_file_path=output_debug_file_path)
72+
5873
resource_kind = re.search(r".*?KIND:\s+(.*?)\n", explain_out)
5974
if not resource_kind:
6075
LOGGER.error(f"Failed to get resource kind from explain for {kind}")
@@ -64,6 +79,9 @@ def get_kind_data(kind: str) -> Dict[str, Any]:
6479
command=shlex.split(f"bash -c 'oc api-resources --namespaced | grep -w {resource_kind.group(1)} | wc -l'"),
6580
check=False,
6681
)
82+
if debug:
83+
write_to_file(kind=kind, data={"namespace": namespace_out}, output_debug_file_path=output_debug_file_path)
84+
6785
if namespace_out.strip() == "1":
6886
return {"data": explain_out, "namespaced": True}
6987

@@ -75,12 +93,27 @@ def format_resource_kind(resource_kind: str) -> str:
7593
return re.sub(r"(?<!^)(?<=[a-z])(?=[A-Z])", "_", resource_kind).lower().strip()
7694

7795

78-
def get_field_description(kind: str, field_name: str, field_under_spec: bool) -> str:
79-
_, _out, _ = run_command(
80-
command=shlex.split(f"oc explain {kind}{'.spec' if field_under_spec else ''}.{field_name}"),
81-
check=False,
82-
verify_stderr=False,
83-
)
96+
def get_field_description(
97+
kind: str,
98+
field_name: str,
99+
field_under_spec: bool,
100+
debug: bool,
101+
output_debug_file_path: str = "",
102+
debug_content: Optional[Dict[str, str]] = None,
103+
) -> str:
104+
if debug_content:
105+
_out = debug_content[f"explain-{field_name}"]
106+
107+
else:
108+
_, _out, _ = run_command(
109+
command=shlex.split(f"oc explain {kind}{'.spec' if field_under_spec else ''}.{field_name}"),
110+
check=False,
111+
verify_stderr=False,
112+
)
113+
114+
if debug:
115+
write_to_file(kind=kind, data={f"explain-{field_name}": _out}, output_debug_file_path=output_debug_file_path)
116+
84117
_description = re.search(r"DESCRIPTION:\n\s*(.*)", _out, re.DOTALL)
85118
if _description:
86119
description: str = ""
@@ -102,7 +135,14 @@ def get_field_description(kind: str, field_name: str, field_under_spec: bool) ->
102135
return "<please add description>"
103136

104137

105-
def get_arg_params(field: str, kind: str, field_under_spec: bool = False) -> Dict[str, Any]:
138+
def get_arg_params(
139+
field: str,
140+
kind: str,
141+
field_under_spec: bool = False,
142+
debug: bool = False,
143+
output_debug_file_path: str = "",
144+
debug_content: Optional[Dict[str, str]] = None,
145+
) -> Dict[str, Any]:
106146
splited_field = field.split()
107147
_orig_name, _type = splited_field[0], splited_field[1]
108148

@@ -134,7 +174,14 @@ def get_arg_params(field: str, kind: str, field_under_spec: bool = False) -> Dic
134174
"type-for-class-arg": f"{name}: {type_from_dict_for_init}",
135175
"required": required,
136176
"type": type_from_dict,
137-
"description": get_field_description(kind=kind, field_name=_orig_name, field_under_spec=field_under_spec),
177+
"description": get_field_description(
178+
kind=kind,
179+
field_name=_orig_name,
180+
field_under_spec=field_under_spec,
181+
debug=debug,
182+
output_debug_file_path=output_debug_file_path,
183+
debug_content=debug_content,
184+
),
138185
}
139186

140187
return _res
@@ -188,6 +235,9 @@ def parse_explain(
188235
api_link: str,
189236
output: str,
190237
namespaced: Optional[bool] = None,
238+
debug: bool = False,
239+
output_debug_file_path: str = "",
240+
debug_content: Optional[Dict[str, str]] = None,
191241
) -> Dict[str, Any]:
192242
section_data: str = ""
193243
sections: List[str] = []
@@ -249,13 +299,19 @@ def parse_explain(
249299
first_field_indent = len(re.findall(r" +", field)[0])
250300
first_field_indent_str = f"{' ' * first_field_indent}"
251301
if not ignored_field and not start_spec_field:
252-
resource_dict[FIELDS_STR].append(get_arg_params(field=field, kind=kind))
302+
resource_dict[FIELDS_STR].append(
303+
get_arg_params(field=field, kind=kind, debug=debug, output_debug_file_path=output_debug_file_path)
304+
)
253305

254306
continue
255307
else:
256308
if len(re.findall(r" +", field)[0]) == len(first_field_indent_str):
257309
if not ignored_field and not start_spec_field:
258-
resource_dict[FIELDS_STR].append(get_arg_params(field=field, kind=kind))
310+
resource_dict[FIELDS_STR].append(
311+
get_arg_params(
312+
field=field, kind=kind, debug=debug, output_debug_file_path=output_debug_file_path
313+
)
314+
)
259315

260316
if start_spec_field:
261317
first_field_spec_found = True
@@ -265,7 +321,16 @@ def parse_explain(
265321
if field_spec_found:
266322
if not re.findall(rf"^{first_field_indent_str}\w", field):
267323
if first_field_spec_found:
268-
resource_dict[SPEC_STR].append(get_arg_params(field=field, kind=kind, field_under_spec=True))
324+
resource_dict[SPEC_STR].append(
325+
get_arg_params(
326+
field=field,
327+
kind=kind,
328+
field_under_spec=True,
329+
debug=debug,
330+
debug_content=debug_content,
331+
output_debug_file_path=output_debug_file_path,
332+
)
333+
)
269334

270335
# Get top level keys inside spec indent, need to match only once.
271336
top_spec_indent = len(re.findall(r" +", field)[0])
@@ -276,7 +341,16 @@ def parse_explain(
276341
if top_spec_indent_str:
277342
# Get only top level keys from inside spec
278343
if re.findall(rf"^{top_spec_indent_str}\w", field):
279-
resource_dict[SPEC_STR].append(get_arg_params(field=field, kind=kind, field_under_spec=True))
344+
resource_dict[SPEC_STR].append(
345+
get_arg_params(
346+
field=field,
347+
kind=kind,
348+
field_under_spec=True,
349+
debug=debug,
350+
debug_content=debug_content,
351+
output_debug_file_path=output_debug_file_path,
352+
)
353+
)
280354
continue
281355

282356
else:
@@ -288,13 +362,34 @@ def parse_explain(
288362

289363
LOGGER.debug(f"\n{yaml.dump(resource_dict)}")
290364

291-
if api_group_real_name := resource_dict.get("GROUP"):
365+
api_group_real_name = resource_dict.get("GROUP")
366+
# If API Group is not present in resource, try to get it from VERSION
367+
if not api_group_real_name:
368+
version_splited = resource_dict["VERSION"].split("/")
369+
if len(version_splited) == 2:
370+
api_group_real_name = version_splited[0]
371+
372+
if api_group_real_name:
292373
api_group_for_resource_api_group = api_group_real_name.upper().replace(".", "_")
374+
resource_dict["GROUP"] = api_group_for_resource_api_group
293375
missing_api_group_in_resource: bool = not hasattr(Resource.ApiGroup, api_group_for_resource_api_group)
294376

295377
if missing_api_group_in_resource:
296378
LOGGER.warning(
297-
f"Missing API Group in Resource\nPlease add `Resource.ApiGroup.{api_group_real_name} = {api_group_real_name}` manually into ocp_resources/resource.py under Resource class > ApiGroup class."
379+
f"Missing API Group in Resource\n"
380+
f"Please add `Resource.ApiGroup.{api_group_for_resource_api_group} = {api_group_real_name}` "
381+
"manually into ocp_resources/resource.py under Resource class > ApiGroup class."
382+
)
383+
384+
else:
385+
api_version_for_resource_api_version = resource_dict["VERSION"].upper()
386+
missing_api_version_in_resource: bool = not hasattr(Resource.ApiVersion, api_version_for_resource_api_version)
387+
388+
if missing_api_version_in_resource:
389+
LOGGER.warning(
390+
f"Missing API Version in Resource\n"
391+
f"Please add `Resource.ApiVersion.{api_version_for_resource_api_version} = {resource_dict['VERSION']}` "
392+
"manually into ocp_resources/resource.py under Resource class > ApiGroup class."
298393
)
299394

300395
return resource_dict
@@ -344,43 +439,62 @@ def get_user_args_from_interactive() -> Tuple[str, str]:
344439
is_flag=True,
345440
help="Output file overwrite existing file if passed",
346441
)
347-
@click.option("-v", "--verbose", is_flag=True, help="Enable debug logs")
442+
@click.option("-d", "--debug", is_flag=True, help="Save all command output to debug file")
348443
@click.option("-i", "--interactive", is_flag=True, help="Enable interactive mode")
349444
@click.option("--dry-run", is_flag=True, help="Run the script without writing to file")
350-
def main(kind: str, api_link: str, verbose: bool, overwrite: bool, interactive: bool, dry_run: bool) -> None:
445+
@click.option("--debug-file", type=click.Path(exists=True), help="Run the script from debug file. (generated by -d)")
446+
def main(
447+
kind: str, api_link: str, overwrite: bool, interactive: bool, dry_run: bool, debug: bool, debug_file: str
448+
) -> None:
351449
"""
352450
Generates a class for a given Kind.
353451
"""
354-
LOGGER.setLevel("DEBUG" if verbose else "INFO")
355-
if not check_cluster_available():
356-
LOGGER.error(
357-
"Cluster not available, The script needs a running cluster and admin privileges to get the explain output"
358-
)
359-
return
452+
debug_content: Dict[str, str] = {}
453+
output_debug_file_path = os.path.join(os.path.dirname(__file__), "debug", f"{kind}-debug.json")
360454

361-
if interactive:
362-
kind, api_link = get_user_args_from_interactive()
455+
if debug_file:
456+
dry_run = True
457+
debug = False
458+
with open(debug_file) as fd:
459+
debug_content = json.load(fd)
363460

364-
if not kind or not api_link:
365-
LOGGER.error("Kind or API link not provided")
366-
return
461+
kind_data = debug_content["explain"]
462+
namespaced = debug_content["namespace"] == "1"
463+
api_link = "https://debug.explain"
367464

368-
validate_api_link_schema(value=api_link)
465+
else:
466+
if not check_cluster_available():
467+
LOGGER.error(
468+
"Cluster not available, The script needs a running cluster and admin privileges to get the explain output"
469+
)
470+
return
369471

370-
if not check_kind_exists(kind=kind):
371-
return
472+
if interactive:
473+
kind, api_link = get_user_args_from_interactive()
372474

373-
explain_output = get_kind_data(kind=kind)
374-
if not explain_output:
375-
return
475+
if not kind or not api_link:
476+
LOGGER.error("Kind or API link not provided")
477+
return
478+
479+
validate_api_link_schema(value=api_link)
376480

377-
namespaced = explain_output["namespaced"]
378-
kind_data = explain_output["data"]
481+
if not check_kind_exists(kind=kind):
482+
return
483+
484+
explain_output = get_kind_data(kind=kind, debug=debug, output_debug_file_path=output_debug_file_path)
485+
if not explain_output:
486+
return
487+
488+
namespaced = explain_output["namespaced"]
489+
kind_data = explain_output["data"]
379490

380491
resource_dict = parse_explain(
381492
output=kind_data,
382493
namespaced=namespaced,
383494
api_link=api_link,
495+
debug=debug,
496+
output_debug_file_path=output_debug_file_path,
497+
debug_content=debug_content,
384498
)
385499
if not resource_dict:
386500
return
@@ -390,6 +504,9 @@ def main(kind: str, api_link: str, verbose: bool, overwrite: bool, interactive:
390504
if not dry_run:
391505
run_command(command=shlex.split("pre-commit run --all-files"), verify_stderr=False, check=False)
392506

507+
if debug:
508+
LOGGER.info(f"Debug output saved to {output_debug_file_path}")
509+
393510

394511
if __name__ == "__main__":
395512
main()

scripts/resource/manifests/class_generator_template.j2

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ class {{ KIND }}({{ BASE_CLASS }}):
2222
{% if GROUP %}
2323
api_group: str = {{ BASE_CLASS }}.ApiGroup.{{ GROUP.upper() }}
2424
{% else %}
25-
api_version: str = "{{ VERSION }}"
25+
api_version: str = {{ BASE_CLASS }}.ApiVersion.{{ VERSION.upper() }}
2626
{% endif %}
2727

2828
def __init__(

0 commit comments

Comments
 (0)