From be5c4b7d63469128f85af80d251668ca8530e8ab Mon Sep 17 00:00:00 2001 From: Dmitry Tantsur Date: Mon, 27 May 2024 18:44:03 +0200 Subject: [PATCH] Render the redfish interop profile in the docs Adds a pretty straightforward Sphinx plugin that reads the JSON profile file and renders it nicely in a document that is then included from the Redfish page. Change-Id: Ic2da61cb510897eac8a2e162816cfd05cc22994c --- .gitignore | 1 + doc/source/_exts/redfish_interop.py | 187 +++++++++++++++++++ doc/source/admin/drivers/redfish/interop.rst | 5 +- doc/source/conf.py | 7 +- 4 files changed, 198 insertions(+), 2 deletions(-) create mode 100644 doc/source/_exts/redfish_interop.py diff --git a/.gitignore b/.gitignore index 01477c2b6f..15f7bb380a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ _build doc/source/contributor/api/ _static +doc/source/admin/drivers/redfish/OpenStackIronicProfile.*.rst # release notes build releasenotes/build diff --git a/doc/source/_exts/redfish_interop.py b/doc/source/_exts/redfish_interop.py new file mode 100644 index 0000000000..cf8d7cfd7b --- /dev/null +++ b/doc/source/_exts/redfish_interop.py @@ -0,0 +1,187 @@ +# 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 json +import os + +from sphinx.application import Sphinx + +__version__ = "1.0.0" + + +# Data model # + + +class Entity: + """Represents an entity in the profile.""" + + def __init__(self, name, src): + self.name = name + self.src = src + self.purpose = src.get('Purpose', '') + self.writable = src.get('WriteRequirement') == 'Mandatory' + self.required = (src.get('ReadRequirement') in ('Mandatory', None) + or self.writable) + + +class ActionParameter(Entity): + """Represents a parameter in an Action.""" + + def __init__(self, name, src): + super().__init__(name, src) + self.required_values = src.get('ParameterValues') or [] + self.recommended_values = src.get('RecommendedValues') or [] + + +class Action(Entity): + """Represents an action on a resource.""" + + def __init__(self, name, src): + super().__init__(name, src) + self.parameters = { + name: ActionParameter(name, value) + for name, value in src.get('Parameters', {}).items() + } + + +class Resource(Entity): + """Represents any resource in the profile. + + Both top-level resources and nested fields are represented by this class + (but actions are not). + """ + + def __init__(self, name, src): + super().__init__(name, src) + self.min_support_values = src.get('MinSupportValues') + self.properties = { + name: Resource(name, value) + for name, value in src.get('PropertyRequirements', {}).items() + } + self.actions = { + name: Action(name, value) + for name, value in src.get('ActionRequirements', {}).items() + } + self.link_to = (src['Values'][0] + if src.get('Comparison') == 'LinkToResource' + else None) + + +# Rendering # + +LEVELS = {0: '=', 1: '-', 2: '~', 3: '^'} +INDENT = ' ' * 4 + + +class NestedWriter: + """A writer that is nested with indentations.""" + + def __init__(self, dest, level=0): + self.dest = dest + self.level = level + + def text(self, text): + print(INDENT * self.level + text, file=self.dest) + + def para(self, text): + self.text(text) + print(file=self.dest) + + def _nested_common(self, res): + required = " **[required]**" if res.required else "" + writable = " **[writable]**" if res.writable else "" + self.text(f"``{res.name}``{required}{writable}") + nested = NestedWriter(self.dest, self.level + 1) + if res.purpose: + nested.para(res.purpose) + return nested + + def action(self, res): + nested = self._nested_common(res) + for prop in res.parameters.values(): + nested.action_parameter(prop) + print(file=self.dest) + + def action_parameter(self, res): + self._nested_common(res) + print(file=self.dest) + + def resource(self, res): + nested = self._nested_common(res) + for prop in res.properties.values(): + nested.resource(prop) + if res.link_to: + # NOTE(dtantsur): this is a bit hacky, but we don't have + # definitions for all possible collections. + split = res.link_to.split('Collection') + if len(split) > 1: + nested.text("Link to a collection of " + f":ref:`Redfish-{split[0]}` resources.") + else: + nested.text(f"Link to a :ref:`Redfish-{res.link_to}` " + "resource.") + + print(file=self.dest) + + +class Writer(NestedWriter): + + def __init__(self, dest): + super().__init__(dest) + + def title(self, text, level=1): + print(text, file=self.dest) + print(LEVELS[level] * len(text), file=self.dest) + + def top_level(self, res): + required = " **[required]**" if res.required else "" + self.para(f".. _Redfish-{res.name}:") + self.title(f"{res.name}") + self.para(f"{res.purpose}{required}") + if res.properties: + self.title("Properties", level=2) + for name, prop in res.properties.items(): + self.resource(prop) + if res.actions: + self.title("Actions", level=2) + for name, act in res.actions.items(): + self.action(act) + + +def builder_inited(app: Sphinx): + source = os.path.join(app.srcdir, app.config.redfish_interop_source) + with open(source) as fp: + profile = json.load(fp) + fname = os.path.basename(source).replace('json', 'rst') + dstdir = os.path.join(app.srcdir, app.config.redfish_interop_output_dir) + with open(os.path.join(dstdir, fname), 'wt') as dest: + w = Writer(dest) + w.title(f"{profile['ProfileName']} {profile['ProfileVersion']}", 0) + w.para(profile['Purpose']) + + try: + for name, value in sorted( + (name, value) + for name, value in profile['Resources'].items() + ): + w.top_level(Resource(name, value)) + except Exception: + import traceback + traceback.print_exc() + raise + + +def setup(app: Sphinx): + app.connect('builder-inited', builder_inited) + app.add_config_value('redfish_interop_source', None, 'env', [str]) + app.add_config_value('redfish_interop_output_dir', None, 'env', [str]) + return {'version': __version__} diff --git a/doc/source/admin/drivers/redfish/interop.rst b/doc/source/admin/drivers/redfish/interop.rst index fc21ef6e2a..f00a070cc1 100644 --- a/doc/source/admin/drivers/redfish/interop.rst +++ b/doc/source/admin/drivers/redfish/interop.rst @@ -18,6 +18,10 @@ and conformance testing. Many of the properties defined within this structure have assumed default values that correspond with the most common use case, so that those properties can be omitted from the document for brevity. +.. toctree:: + + OpenStackIronicProfile.v1_1_0 + Validation of Profiles using DMTF tool ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -27,4 +31,3 @@ Redfish Interoperability Profile. The Redfish Interop Validator is available for download from the DMTF's organization on Github at https://github.com/DMTF/Redfish-Interop-Validator. Refer to instructions in README on how to configure and run validation. - diff --git a/doc/source/conf.py b/doc/source/conf.py index b3a3aa367b..6d8cdfe84c 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -42,7 +42,8 @@ extensions = ['sphinx.ext.viewcode', 'oslo_policy.sphinxpolicygen', 'automated_steps', 'openstackdocstheme', - 'web_api_docstring' + 'web_api_docstring', + 'redfish_interop', ] # sphinxcontrib.apidoc options @@ -61,6 +62,10 @@ autodoc_default_options = { 'special-members': '__call__', } +redfish_interop_source = \ + '../../redfish-interop-profiles/OpenStackIronicProfile.v1_1_0.json' +redfish_interop_output_dir = 'admin/drivers/redfish/' + openstackdocs_repo_name = 'openstack/ironic' openstackdocs_use_storyboard = False openstackdocs_pdf_link = True