diff --git a/codegenerator/metadata/__init__.py b/codegenerator/metadata/__init__.py index 3eb8985..9484388 100644 --- a/codegenerator/metadata/__init__.py +++ b/codegenerator/metadata/__init__.py @@ -25,6 +25,9 @@ from codegenerator.common.schema import SpecSchema from codegenerator.metadata.base import MetadataBase from codegenerator.metadata.baremetal import BaremetalMetadata from codegenerator.metadata.block_storage import BlockStorageMetadata +from codegenerator.metadata.container_infrastructure_management import ( + ContainerInfrastructureManagementMetadata, +) from codegenerator.metadata.compute import ComputeMetadata from codegenerator.metadata.dns import DnsMetadata from codegenerator.metadata.identity import IdentityMetadata @@ -55,6 +58,7 @@ SERVICE_METADATA_MAP: dict[str, ty.Type[MetadataBase]] = { "block-storage": BlockStorageMetadata, "volume": BlockStorageMetadata, "compute": ComputeMetadata, + "container-infrastructure-management": ContainerInfrastructureManagementMetadata, "dns": DnsMetadata, "identity": IdentityMetadata, "image": ImageMetadata, diff --git a/codegenerator/metadata/container_infrastructure_management.py b/codegenerator/metadata/container_infrastructure_management.py new file mode 100644 index 0000000..0347a56 --- /dev/null +++ b/codegenerator/metadata/container_infrastructure_management.py @@ -0,0 +1,33 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +import typing as ty + +from codegenerator.types import OperationModel +from codegenerator.metadata.base import MetadataBase + + +class ContainerInfrastructureManagementMetadata(MetadataBase): + @staticmethod + def get_operation_key( + operation, path: str, method: str, resource_name: str + ) -> ty.Tuple[str | None, bool]: + skip: bool = False + operation_key: str | None = None + + return (operation_key, skip) + + @staticmethod + def post_process_operation( + resource_name: str, operation_name: str, operation + ): + return operation diff --git a/codegenerator/openapi/base.py b/codegenerator/openapi/base.py index fab2e46..a4185cb 100644 --- a/codegenerator/openapi/base.py +++ b/codegenerator/openapi/base.py @@ -285,8 +285,8 @@ class OpenStackServerSourceBase: versioned_methods = {} controller_actions = {} - framework = None - if hasattr(controller, "controller"): + # framework = None + if hasattr(controller, "controller") and framework != "pecan": # wsgi framework = "wsgi" contr = controller.controller @@ -304,9 +304,13 @@ class OpenStackServerSourceBase: # Pecan base app framework = "pecan" contr = controller + if hasattr(controller, "versioned_methods"): + versioned_methods = contr.versioned_methods else: - raise RuntimeError(f"Unsupported controller {controller}") + raise RuntimeError( + f"Unsupported controller {controller} {framework}" + ) # logging.debug("Actions: %s, Versioned methods: %s", actions, versioned_methods) # path_spec = openapi_spec.paths.setdefault(path, PathSchema()) @@ -317,7 +321,6 @@ class OpenStackServerSourceBase: if path_elements and VERSION_RE.match(path_elements[0]): path_elements.pop(0) operation_tags = self._get_tags_for_url(path) - print(f"tags={operation_tags} for {path}") # Build path parameters (/foo/{foo_id}/bar/{id} => $foo_id, $foo_bar_id) # Since for same path we are here multiple times check presence of @@ -745,7 +748,14 @@ class OpenStackServerSourceBase: body_spec = getattr(fdef, "body_type", None) if body_spec: body_schema = _convert_wsme_to_jsonschema(body_spec) - schema_name = body_spec.__name__ + if hasattr(body_spec, "__name__"): + schema_name = body_spec.__name__ + else: + schema_name = ( + "".join([x.title() for x in path_resource_names]) + + func.__name__.title() + + "Request" + ) openapi_spec.components.schemas.setdefault( schema_name, TypeSchema(**body_schema) ) diff --git a/codegenerator/openapi/magnum.py b/codegenerator/openapi/magnum.py new file mode 100644 index 0000000..0728b4d --- /dev/null +++ b/codegenerator/openapi/magnum.py @@ -0,0 +1,277 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +import inspect +import logging +from multiprocessing import Process +from pathlib import Path +from typing import Any +from unittest import mock + +import fixtures + +from codegenerator.common.schema import SpecSchema +from codegenerator.common.schema import TypeSchema +from codegenerator.openapi.base import ( + OpenStackServerSourceBase, + _convert_wsme_to_jsonschema, +) +from codegenerator.openapi.utils import merge_api_ref_doc + +from ruamel.yaml.scalarstring import LiteralScalarString + + +class MagnumGenerator(OpenStackServerSourceBase): + URL_TAG_MAP = {} + + def _api_ver_major(self, ver): + return ver.ver_major + + def _api_ver_minor(self, ver): + return ver.ver_minor + + def _api_ver(self, ver): + return (ver.ver_major, ver.ver_minor) + + def _build_routes(self, mapper, node, path=""): + if hasattr(node, "versioned_methods"): + resource = None + parent = path.split("/")[-1] + # Construct resource name from the path + if parent.endswith("ies"): + resource = parent[0 : len(parent) - 3] + "y" + else: + resource = parent[0:-1] + for method, vers in node.versioned_methods.items(): + url = path + if method == "post": + conditions = {"method": ["POST"]} + elif method == "patch": + conditions = {"method": ["PATCH"]} + url += f"/{{{resource}_id}}" + elif method == "delete": + conditions = {"method": ["DELETE"]} + elif method == "get": + conditions = {"method": ["GET"]} + else: + conditions = {"method": ["POST"]} + if method in getattr(node, "_custom_actions", []): + url += f"/{method}" + conditions = { + "method": getattr(node, "_custom_actions")[method] + } + + mapper.connect( + url, + controller=getattr(node, method), + action=method, + conditions=conditions, + ) + + for part in dir(node): + if part.startswith("_"): + continue + try: + if callable(getattr(node, part)): + # Iterate over functions to find what is exposed on the current + # level + # if part == "versioned_methods" + obj = getattr(node, part) + _pecan = getattr(obj, "_pecan", None) + exposed = getattr(obj, "exposed", None) + if _pecan and exposed: + # Only whatever is pecan exposed is of interest + conditions = {} + action = None + url = path + resource = None + parent = url.split("/")[-1] + # Construct resource name from the path + if parent.endswith("ies"): + resource = parent[0 : len(parent) - 3] + "y" + else: + resource = parent[0:-1] + # Identify the action from function name + # https://pecan.readthedocs.io/en/latest/rest.html#url-mapping + if part == "get_one": + conditions["method"] = ["GET"] + action = "show" + url += f"/{{{resource}_id}}" + elif part == "get_all": + conditions["method"] = ["GET"] + action = "list" + elif part == "get": + conditions["method"] = ["GET"] + action = "get" + # "Get" is tricky, it can be normal and root, so need to inspect params + sig = inspect.signature(obj) + for pname, pval in sig.parameters.items(): + if ( + "id" in pname + and pval.default == pval.empty + ): + url += f"/{{{resource}_id}}" + elif part == "post": + conditions["method"] = ["POST"] + action = "create" + # url += f"/{{{resource}_id}}" + elif part == "put": + conditions["method"] = ["PUT"] + action = "update" + url += f"/{{{resource}_id}}" + elif part == "delete": + conditions["method"] = ["DELETE"] + action = "delete" + url += f"/{{{resource}_id}}" + + if action: + # If we identified method as "interesting" register it into + # the routes mapper + mapper.connect( + None, + url, + controller=obj, + action=action, + conditions=conditions, + ) + # yield part + except Exception as ex: + logging.debug(f"method {part} is not callable due to {ex}") + pass + + if not hasattr(node, "__dict__"): + return + for subcontroller, v in node.__class__.__dict__.items(): + # Iterate over node attributes for subcontrollers + if subcontroller.startswith("_"): + continue + + if subcontroller in ["__wrapped__", "__doc__"]: + # Not underested in those + continue + subpath = f"{path}/{subcontroller}" + self._build_routes(mapper, v, subpath) + + return + + def generate(self, target_dir, args): + proc = Process(target=self._generate, args=[target_dir, args]) + proc.start() + proc.join() + if proc.exitcode != 0: + raise RuntimeError("Error generating Magnum OpenAPI schema") + + def _generate(self, target_dir, args): + import pecan.testing + from magnum.api.controllers import versions + from magnum.api.controllers.v1 import Controller + from magnum.api import app + + from oslo_config import cfg + + self.min_api_version = versions.BASE_VER + self.api_version = versions.CURRENT_MAX_VER + + from pecan import make_app as pecan_make_app + from routes import Mapper + + work_dir = Path(target_dir) + work_dir.mkdir(parents=True, exist_ok=True) + + impl_path = Path( + work_dir, + "openapi_specs", + "container-infrastructure-management", + f"v{self.api_version}.yaml", + ) + impl_path.parent.mkdir(parents=True, exist_ok=True) + openapi_spec = self.load_openapi(Path(impl_path)) + if not openapi_spec: + openapi_spec = SpecSchema( + info={ + "title": "OpenStack Container Managent Infrastructure API", + "description": LiteralScalarString( + "Container Management Infrastructure API provided by Magnum service" + ), + "version": self.api_version, + }, + openapi="3.1.0", + security=[{"ApiKeyAuth": []}], + components={ + "securitySchemes": { + "ApiKeyAuth": { + "type": "apiKey", + "in": "header", + "name": "X-Auth-Token", + } + } + }, + ) + + with mock.patch("pecan.request") as m: + self.app = app.setup_app() + self.root = self.app.application.app.root + mapper = Mapper() + + self._build_routes(mapper, self.root) + + for route in mapper.matchlist: + self._process_route(route, openapi_spec, framework="pecan") + + if args.api_ref_src: + merge_api_ref_doc( + openapi_spec, args.api_ref_src, allow_strip_version=False + ) + + self.dump_openapi( + openapi_spec, + Path(impl_path), + args.validate, + "container-infrastructure-management", + ) + + lnk = Path(impl_path.parent, "v1.yaml") + lnk.unlink(missing_ok=True) + lnk.symlink_to(impl_path.name) + + return impl_path + + def _get_schema_ref( + self, + openapi_spec, + name, + description=None, + schema_def=None, + action_name=None, + ): + schema: None = None + ref: str | None + mime_type: str | None = "application/json" + + if name in [ + "LbaasLoadbalancersFailoverFailoverRequest", + "OctaviaAmphoraeFailoverFailoverRequest", + ]: + schema = openapi_spec.components.schemas.setdefault( + name, TypeSchema(type="null") + ) + ref = f"#/components/schemas/{name}" + else: + (ref, mime_type) = super()._get_schema_ref( + openapi_spec, + name, + description, + schema_def=schema_def, + action_name=action_name, + ) + + return (ref, mime_type) diff --git a/codegenerator/openapi_spec.py b/codegenerator/openapi_spec.py index 5dc1b94..6736b29 100644 --- a/codegenerator/openapi_spec.py +++ b/codegenerator/openapi_spec.py @@ -53,6 +53,11 @@ class OpenApiSchemaGenerator(BaseGenerator): OctaviaGenerator().generate(target_dir, args) + def generate_magnum(self, target_dir, args): + from codegenerator.openapi.magnum import MagnumGenerator + + MagnumGenerator().generate(target_dir, args) + def generate_neutron(self, target_dir, args): from codegenerator.openapi.neutron import NeutronGenerator @@ -97,6 +102,12 @@ class OpenApiSchemaGenerator(BaseGenerator): self.generate_ironic(target_dir, args) elif args.service_type in ["block-storage", "volume"]: self.generate_cinder(target_dir, args) + elif args.service_type in [ + "container-infrastructure-management", + "container-infrastructure", + "container-infra", + ]: + self.generate_magnum(target_dir, args) elif args.service_type == "dns": self.generate_designate(target_dir, args) elif args.service_type == "image": diff --git a/zuul.d/openapi.yaml b/zuul.d/openapi.yaml index 68e2005..736e818 100644 --- a/zuul.d/openapi.yaml +++ b/zuul.d/openapi.yaml @@ -120,6 +120,35 @@ project: "opendev.org/openstack/nova" path: "/api-ref/build/html/index.html" +- job: + name: codegenerator-openapi-container-infrastructure-management-tips + parent: codegenerator-openapi-tips-base + description: | + Generate OpenAPI spec for Magnum + required-projects: + - name: openstack/magnum + + vars: + openapi_service: container-infrastructure-management + install_additional_projects: + - project: "opendev.org/openstack/magnum" + name: "." + +- job: + name: codegenerator-openapi-container-infrastructure-management-tips-with-api-ref + parent: codegenerator-openapi-container-infrastructure-management-tips + description: | + Generate OpenAPI spec for Magnum consuming API-REF + required-projects: + - name: openstack/magnum + + pre-run: + - playbooks/openapi/pre-api-ref.yaml + vars: + codegenerator_api_ref: + project: "opendev.org/openstack/magnum" + path: "/api-ref/build/html/index.html" + - job: name: codegenerator-openapi-dns-tips parent: codegenerator-openapi-tips-base @@ -364,6 +393,8 @@ soft: true - name: codegenerator-openapi-compute-tips-with-api-ref soft: true + - name: codegenerator-openapi-container-infrastructure-management-tips-with-api-ref + soft: true - name: codegenerator-openapi-dns-tips-with-api-ref soft: true - name: codegenerator-openapi-identity-tips-with-api-ref diff --git a/zuul.d/project.yaml b/zuul.d/project.yaml index 395f1cc..5f1fb92 100644 --- a/zuul.d/project.yaml +++ b/zuul.d/project.yaml @@ -9,6 +9,7 @@ - codegenerator-openapi-baremetal-tips-with-api-ref - codegenerator-openapi-block-storage-tips-with-api-ref - codegenerator-openapi-compute-tips-with-api-ref + - codegenerator-openapi-container-infrastructure-management-tips-with-api-ref - codegenerator-openapi-dns-tips-with-api-ref - codegenerator-openapi-identity-tips-with-api-ref - codegenerator-openapi-image-tips-with-api-ref @@ -27,6 +28,7 @@ - codegenerator-openapi-baremetal-tips-with-api-ref - codegenerator-openapi-block-storage-tips-with-api-ref - codegenerator-openapi-compute-tips-with-api-ref + - codegenerator-openapi-container-infrastructure-management-tips-with-api-ref - codegenerator-openapi-dns-tips-with-api-ref - codegenerator-openapi-identity-tips-with-api-ref - codegenerator-openapi-image-tips-with-api-ref