Start building Ironic OpenAPI

Change-Id: If1d9a47a9a87c985988b3378cddf0330d14b9458
This commit is contained in:
Artem Goncharov 2024-09-09 18:33:44 +02:00
parent ee8925c71c
commit 8f5dc2e994
8 changed files with 290 additions and 1 deletions

View File

@ -1309,6 +1309,48 @@ class OpenStackServerSourceBase:
getattr(f, "_request_query_schema", {}),
)
query_params_versions.append((obj, min_ver, max_ver))
if "validators" in closure_locals:
validators = closure_locals.get("validators")
body_schemas = []
if isinstance(validators, dict):
for k, v in validators.items():
sig = inspect.signature(v)
vals = sig.parameters.get("validators", None)
if vals:
print(vals)
sig2 = inspect.signature(vals.default[0])
schema_param = sig2.parameters.get("schema", None)
if schema_param:
schema = schema_param.default
typ_name = (
"".join(
[
x.title()
for x in path_resource_names
]
)
+ func.__name__.title()
+ (
f"_{min_ver.replace('.', '')}"
if min_ver
else ""
)
)
comp_schema = (
openapi_spec.components.schemas.setdefault(
typ_name,
self._sanitize_schema(
copy.deepcopy(schema),
start_version=None,
end_version=None,
),
)
)
ref_name = f"#/components/schemas/{typ_name}"
if isinstance(body_schemas, list):
body_schemas.append(ref_name)
f = f.__wrapped__

View File

@ -159,7 +159,7 @@ class CinderV3Generator(OpenStackServerSourceBase):
if route.routepath.startswith(
"/extensions"
) or route.routepath.startswith(
"/{project_id:[0-9a-f\-]+}/extensions"
"/{project_id:[0-9a-f\\-]+}/extensions"
):
if route.defaults.get("action") != "index":
# Extensions controller is broken as one exposing CRUD

View File

@ -0,0 +1,201 @@
# 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
from multiprocessing import Process
from pathlib import Path
from unittest import mock
import fixtures
from codegenerator.common.schema import SpecSchema
from codegenerator.openapi.base import OpenStackServerSourceBase
from codegenerator.openapi.utils import merge_api_ref_doc
from ruamel.yaml.scalarstring import LiteralScalarString
class IronicGenerator(OpenStackServerSourceBase):
URL_TAG_MAP = {}
def __init__(self):
pass
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=""):
resource: str | None = None
# Construct resource name from the path
parent = path.split("/")[-1]
if parent == "v1":
resource = ""
elif parent.endswith("ies"):
resource = parent[0 : len(parent) - 3] + "y"
elif parent in ["allocation", "history", "vmedia", "chassis", "bios"]:
resource = parent
else:
resource = parent[0:-1]
for part in [x for x in dir(node) if callable(getattr(node, x))]:
# Iterate over functions to find what is exposed on the current
# level
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]
# if path.startswith("/v2/lbaas/quotas"):
# # Hack path parameter name for quotas
# resource = "project"
# 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 == "patch":
conditions["method"] = ["PATCH"]
action = "update"
url += f"/{{{resource}_id}}"
elif part == "delete":
conditions["method"] = ["DELETE"]
action = "delete"
url += f"/{{{resource}_id}}"
elif part in getattr(node, "_custom_actions", {}):
conditions["method"] = getattr(
node, "_custom_actions", {}
)[part]
action = part
url += f"/{part}"
if action:
# If we identified method as "interesting" register it into
# the routes mapper
mapper.connect(
None,
url,
controller=obj,
action=action,
conditions=conditions,
)
for subcontroller, v in getattr(
node, "_subcontroller_map", {}
).items():
if resource:
subpath = f"{path}/{{{resource}_id}}/{subcontroller}"
else:
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 Octavia OpenAPI schema")
def _generate(self, target_dir, args):
from ironic.api.controllers.v1 import versions
from ironic.api.controllers import root as root_controller
from ironic.api.controllers import v1
from pecan import make_app as pecan_make_app
from routes import Mapper
self.api_version = versions.max_version_string()
self.min_api_version = versions.min_version_string()
work_dir = Path(target_dir)
work_dir.mkdir(parents=True, exist_ok=True)
impl_path = Path(
work_dir, "openapi_specs", "baremetal", 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 Baremetal API",
"description": LiteralScalarString(
"Baremetal API provided by Ironic service"
),
"version": self.api_version,
},
openapi="3.1.0",
security=[{"ApiKeyAuth": []}],
components={
"securitySchemes": {
"ApiKeyAuth": {
"type": "apiKey",
"in": "header",
"name": "X-Auth-Token",
}
}
},
)
self.app = pecan_make_app(root_controller.RootController())
self.root = self.app.application.root
mapper = Mapper()
self._build_routes(mapper, v1.Controller, "/v1")
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)
lnk = Path(impl_path.parent, "v1.yaml")
lnk.unlink(missing_ok=True)
lnk.symlink_to(impl_path.name)
return impl_path

View File

@ -73,6 +73,11 @@ class OpenApiSchemaGenerator(BaseGenerator):
DesignateGenerator().generate(target_dir, args)
def generate_ironic(self, target_dir, args):
from codegenerator.openapi.ironic import IronicGenerator
IronicGenerator().generate(target_dir, args)
def generate(
self, res, target_dir, openapi_spec=None, operation_id=None, args=None
):
@ -83,6 +88,8 @@ class OpenApiSchemaGenerator(BaseGenerator):
# dramatically
if args.service_type == "compute":
self.generate_nova(target_dir, args)
elif args.service_type == "baremetal":
self.generate_ironic(target_dir, args)
elif args.service_type in ["block-storage", "volume"]:
self.generate_cinder(target_dir, args)
elif args.service_type == "dns":

View File

@ -45,6 +45,8 @@ placement =
openstack-placement>=10.0
shared-file-system =
manila>=18.0
baremetal =
ironic>=26.0
[mypy]
show_column_numbers = true

View File

@ -38,3 +38,7 @@ fi
if [ -z "$1" -o "$1" = "dns" ]; then
openstack-codegenerator --work-dir wrk --target openapi-spec --service-type dns --api-ref-src ${API_REF_BUILD_ROOT}/designate/api-ref/build/html/dns-api-v2-index.html --validate
fi
if [ -z "$1" -o "$1" = "baremetal" ]; then
openstack-codegenerator --work-dir wrk --target openapi-spec --service-type baremetal --api-ref-src ${API_REF_BUILD_ROOT}/ironic/api-ref/build/html/index.html --validate
fi

View File

@ -33,6 +33,35 @@
codegenerator_work_dir: "wrk"
install_additional_projects: []
- job:
name: codegenerator-openapi-baremetal-tips
parent: codegenerator-openapi-tips-base
description: |
Generate OpenAPI spec for Ironic
required-projects:
- name: openstack/ironic
vars:
openapi_service: baremetal
install_additional_projects:
- project: "opendev.org/openstack/ironic"
name: "."
- job:
name: codegenerator-openapi-baremetal-tips-with-api-ref
parent: codegenerator-openapi-baremetal-tips
description: |
Generate OpenAPI spec for Ironic consuming API-REF
required-projects:
- name: openstack/ironic
pre-run:
- playbooks/openapi/pre-api-ref.yaml
vars:
codegenerator_api_ref:
project: "opendev.org/openstack/ironic"
path: "/api-ref/build/html/index.html"
- job:
name: codegenerator-openapi-block-storage-tips
parent: codegenerator-openapi-tips-base
@ -315,6 +344,8 @@
description: |
Published OpenAPI specs
dependencies:
- name: codegenerator-openapi-baremetal-tips-with-api-ref
soft: true
- name: codegenerator-openapi-block-storage-tips-with-api-ref
soft: true
- name: codegenerator-openapi-compute-tips-with-api-ref

View File

@ -6,6 +6,7 @@
jobs:
- openstack-tox-pep8
- openstack-tox-py311
- codegenerator-openapi-baremetal-tips-with-api-ref
- codegenerator-openapi-block-storage-tips-with-api-ref
- codegenerator-openapi-compute-tips-with-api-ref
- codegenerator-openapi-dns-tips-with-api-ref
@ -22,6 +23,7 @@
jobs:
- openstack-tox-pep8
- openstack-tox-py311
- codegenerator-openapi-baremetal-tips-with-api-ref
- codegenerator-openapi-block-storage-tips-with-api-ref
- codegenerator-openapi-compute-tips-with-api-ref
- codegenerator-openapi-dns-tips-with-api-ref