diff --git a/.coveragerc b/.coveragerc index f45d51a87..ba45bdd9a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,3 +1,7 @@ +[run] +branch = True +source = ironicclient +omit = ironicclient/tests/* + [report] -include = ironicclient/* -omit = ironicclient/tests/functional/* +ignore_errors = True diff --git a/.gitignore b/.gitignore index 6a6b9a94f..ffb6c75e6 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,7 @@ # Sphinx _build -doc/source/api/ +doc/source/reference/api/ # Release notes releasenotes/build @@ -16,6 +16,7 @@ releasenotes/build *.egg-info dist build +.eggs eggs parts var @@ -25,7 +26,7 @@ develop-eggs # Other *.DS_Store -.testrepository +.stestr .tox .idea .venv diff --git a/.gitreview b/.gitreview index 1ed269672..130a080b0 100644 --- a/.gitreview +++ b/.gitreview @@ -1,4 +1,4 @@ [gerrit] -host=review.openstack.org +host=review.opendev.org port=29418 project=openstack/python-ironicclient.git diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..bdfcdcf02 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,6 @@ +repos: + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.14.1 + hooks: + - id: mypy + args: [--config-file=pyproject.toml] diff --git a/.stestr.conf b/.stestr.conf new file mode 100644 index 000000000..c50970703 --- /dev/null +++ b/.stestr.conf @@ -0,0 +1,3 @@ +[DEFAULT] +test_path=${TESTS_DIR:-./ironicclient/tests/unit} +top_dir=./ diff --git a/.testr.conf b/.testr.conf deleted file mode 100644 index 984a59737..000000000 --- a/.testr.conf +++ /dev/null @@ -1,5 +0,0 @@ -[DEFAULT] -test_command=OS_STDOUT_CAPTURE=${OS_STDOUT_CAPTURE:-1} OS_STDERR_CAPTURE=${OS_STDERR_CAPTURE:-1} ${PYTHON:-python} -m subunit.run discover -t ./ ${TESTS_DIR:-./ironicclient/tests/unit} $LISTOPT $IDOPTION - -test_id_option=--load-list $IDFILE -test_list_option=--list diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index fa92b9dbe..b98a01425 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -1,9 +1,9 @@ If you would like to contribute to the development of OpenStack, you must follow the steps documented at: - http://docs.openstack.org/infra/manual/developers.html + https://docs.openstack.org/infra/manual/developers.html More information on contributing can be found within the project documentation: - http://docs.openstack.org/developer/python-ironicclient/contributing.html + https://docs.openstack.org/python-ironicclient/latest/contributor/contributing.html diff --git a/README.rst b/README.rst index ab790fe6b..ef767496a 100644 --- a/README.rst +++ b/README.rst @@ -1,35 +1,47 @@ -======================== +================================== +Python bindings for the Ironic API +================================== + Team and repository tags -======================== +------------------------ -.. image:: http://governance.openstack.org/badges/python-ironicclient.svg - :target: http://governance.openstack.org/reference/tags/index.html +.. image:: https://governance.openstack.org/tc/badges/python-ironicclient.svg + :target: https://governance.openstack.org/tc/reference/tags/index.html -.. Change things from this point on +Overview +-------- -Python bindings for the Ironic API -================================== +This is a client for the OpenStack `Bare Metal API +`_. It provides: -This is a client for the OpenStack `Ironic -`_ API. It provides a Python API (the -``ironicclient`` module) and a command-line interface (``ironic``). +* a Python API: the ``ironicclient`` module, and +* a command-line interfaces: ``openstack baremetal`` Development takes place via the usual OpenStack processes as outlined in the -`developer guide `_. The master -repository is on `git.openstack.org -`_. - -``python-ironicclient`` is licensed under the Apache License like the rest -of OpenStack. +`developer guide `_. +The master repository is on `opendev.org +`_. +``python-ironicclient`` is licensed under the Apache License, Version 2.0, +like the rest of OpenStack. .. contents:: Contents: :local: +Project resources +----------------- + +* Documentation: https://docs.openstack.org/python-ironicclient/latest/ +* Source: https://opendev.org/openstack/python-ironicclient +* PyPi: https://pypi.org/project/python-ironicclient +* Bugs: https://storyboard.openstack.org/#!/project/959 +* Release notes: https://docs.openstack.org/releasenotes/python-ironicclient/ + Python API ---------- Quick-start Example:: + >>> from ironicclient import client >>> >>> kwargs = {'os_auth_token': '3bcc3d3a03f44e3d8377f9247b0ad155', @@ -37,57 +49,27 @@ Quick-start Example:: >>> ironic = client.get_client(1, **kwargs) -Command-line API ----------------- - -This package will install the ``ironic`` command line interface that you -can use to interact with the ``ironic`` API. - -In order to use the ``ironic`` CLI you'll need to provide your OpenStack -tenant, username, password and authentication endpoint. You can do this with -the ``--os-tenant-name``, ``--os-username``, ``--os-password`` and -``--os-auth-url`` parameters, though it may be easier to set them -as environment variables:: - - $ export OS_PROJECT_NAME=project - $ export OS_USERNAME=user - $ export OS_PASSWORD=pass - $ export OS_AUTH_URL=http://auth.example.com:5000/v2.0 +``openstack baremetal`` CLI +--------------------------- -To use a specific Ironic API endpoint:: +The ``openstack baremetal`` command line interface is available when the bare +metal plugin (included in this package) is used with the `OpenStackClient +`_. - $ export IRONIC_URL=http://ironic.example.com:6385 +There are two ways to install the OpenStackClient (python-openstackclient) +package: -An example of creating a basic node with the pxe_ipmitool driver:: +* along with this python-ironicclient package:: - $ ironic node-create -d pxe_ipmitool + # pip install python-ironicclient[cli] -An example of creating a port on a node:: - - $ ironic port-create -a AA:BB:CC:DD:EE:FF -n nodeUUID - -An example of updating driver properties for a node:: - - $ ironic node-update nodeUUID add driver_info/ipmi_address= - $ ironic node-update nodeUUID add driver_info/ipmi_username= - $ ironic node-update nodeUUID add driver_info/ipmi_password= - - -For more information about the ``ironic`` command and the subcommands -available, run:: +* directly:: - $ ironic help + # pip install python-openstackclient -OpenStackClient Baremetal Plugin --------------------------------- +An example of creating a basic node with the ``ipmi`` driver:: -In order to use Baremetal Plugin the OpenStackClient should be installed:: - - # pip install python-openstackclient - -An example of creating a basic node with the agent_ipmitool driver:: - - $ openstack baremetal node create --driver agent_ipmitool + $ openstack baremetal node create --driver ipmi An example of creating a port on a node:: @@ -101,13 +83,3 @@ For more information about the ``openstack baremetal`` command and the subcommands available, run:: $ openstack help baremetal - -* License: Apache License, Version 2.0 -* Documentation: http://docs.openstack.org/developer/python-ironicclient -* Source: http://git.openstack.org/cgit/openstack/python-ironicclient -* Bugs: http://bugs.launchpad.net/python-ironicclient - -Change logs with information about specific versions (or tags) are -available at: - - ``_. diff --git a/bindep.txt b/bindep.txt new file mode 100644 index 000000000..0b2a2b7fb --- /dev/null +++ b/bindep.txt @@ -0,0 +1,4 @@ +# install fonts missing for PDF job +# see https://bugs.launchpad.net/openstack-i18n/+bug/1935742 +tex-gyre [platform:dpkg doc] + diff --git a/doc/requirements.txt b/doc/requirements.txt new file mode 100644 index 000000000..7993d4722 --- /dev/null +++ b/doc/requirements.txt @@ -0,0 +1,4 @@ +openstackdocstheme>=2.2.0 # Apache-2.0 +reno>=3.1.0 # Apache-2.0 +sphinx>=2.0.0 # BSD +sphinxcontrib-apidoc>=0.2.0 # BSD diff --git a/doc/source/api_v1.rst b/doc/source/api_v1.rst index cd5c866cf..3072a560a 100644 --- a/doc/source/api_v1.rst +++ b/doc/source/api_v1.rst @@ -8,24 +8,26 @@ The ironicclient python API lets you access ironic, the OpenStack Bare Metal Provisioning Service. For example, to manipulate nodes, you interact with an -`ironicclient.v1.node`_ object. +:py:class:`ironicclient.v1.node.Node` object. You obtain access to nodes via attributes of the -`ironicclient.v1.client.Client`_ object. +:py:class:`ironicclient.v1.client.Client` object. Usage ===== Get a Client object ------------------- -First, create an `ironicclient.v1.client.Client`_ instance by passing your -credentials to `ironicclient.client.get_client()`_. By default, the -Bare Metal Provisioning system is configured so that only administrators +First, create an :py:class:`ironicclient.v1.client.Client` instance by passing +your credentials to :py:meth:`ironicclient.client.get_client()`. By default, +the Bare Metal Provisioning system is configured so that only administrators (users with 'admin' role) have access. .. note:: - Explicit instantiation of `ironicclient.v1.client.Client`_ may cause - errors since it doesn't verify provided arguments, using - `ironicclient.client.get_client()` is preferred way to get client object. + + Explicit instantiation of :py:class:`ironicclient.v1.client.Client` may + cause errors since it doesn't verify provided arguments, using + :py:meth:`ironicclient.client.get_client()` is preferred way to get client + object. There are two different sets of credentials that can be used:: @@ -61,6 +63,13 @@ These Identity Service credentials can be used to authenticate:: service. default: False (optional) * os_tenant_{name|id}: name or ID of tenant +Also the following parameters are required when using the Identity API v3:: + + * os_user_domain_name: name of a domain the user belongs to, + usually 'default' + * os_project_domain_name: name of a domain the project belongs to, + usually 'default' + To create a client, you can use the API like so:: >>> from ironicclient import client @@ -74,30 +83,20 @@ To create a client, you can use the API like so:: Perform ironic operations ------------------------- -Once you have an ironic `Client`_, you can perform various tasks:: +Once you have an :py:class:`ironicclient.v1.client.Client`, you can perform +various tasks:: >>> ironic.driver.list() # list of drivers >>> ironic.node.list() # list of nodes >>> ironic.node.get(node_uuid) # information about a particular node -When the `Client`_ needs to propagate an exception, it will usually -raise an instance subclassed from -`ironicclient.exc.BaseException`_ or `ironicclient.exc.ClientException`_. +When the Client needs to propagate an exception, it will usually raise an +instance subclassed from +:py:class:`ironicclient.common.apiclient.exceptions.ClientException`. Refer to the modules themselves, for more details. ironicclient Modules ==================== -.. toctree:: - :maxdepth: 1 - - modules - - -.. _ironicclient.v1.node: api/ironicclient.v1.node.html#ironicclient.v1.node.Node -.. _ironicclient.v1.client.Client: api/ironicclient.v1.client.html#ironicclient.v1.client.Client -.. _Client: api/ironicclient.v1.client.html#ironicclient.v1.client.Client -.. _ironicclient.client.get_client(): api/ironicclient.client.html#ironicclient.client.get_client -.. _ironicclient.exc.BaseException: api/ironicclient.exc.html#ironicclient.exc.BaseException -.. _ironicclient.exc.ClientException: api/ironicclient.exc.html#ironicclient.exc.ClientException +* :ref:`modindex` diff --git a/doc/source/cli.rst b/doc/source/cli.rst deleted file mode 100644 index 78f1ee3e0..000000000 --- a/doc/source/cli.rst +++ /dev/null @@ -1,94 +0,0 @@ -============================================== -:program:`ironic` Command-Line Interface (CLI) -============================================== - -.. program:: ironic -.. highlight:: bash - -SYNOPSIS -======== - -:program:`ironic` [options] [command-options] - -:program:`ironic help` - -:program:`ironic help` - - -DESCRIPTION -=========== - -The :program:`ironic` command-line interface (CLI) interacts with the -OpenStack Bare Metal Service (Ironic). - -In order to use the CLI, you must provide your OpenStack username, password, -project (historically called tenant), and auth endpoint. You can use -configuration options :option:`--os-username`, :option:`--os-password`, -:option:`--os-tenant-id` (or :option:`--os-tenant-name`), -and :option:`--os-auth-url`, or set the corresponding -environment variables:: - - $ export OS_USERNAME=user - $ export OS_PASSWORD=password - $ export OS_PROJECT_ID=b363706f891f48019483f8bd6503c54b # or OS_PROJECT_NAME - $ export OS_PROJECT_NAME=project # or OS_PROJECT_ID - $ export OS_AUTH_URL=http://auth.example.com:5000/v2.0 - -The command-line tool will attempt to reauthenticate using the provided -credentials for every request. You can override this behavior by manually -supplying an auth token using :option:`--ironic-url` and -:option:`--os-auth-token`, or by setting the corresponding environment -variables:: - - $ export IRONIC_URL=http://ironic.example.org:6385/ - $ export OS_AUTH_TOKEN=3bcc3d3a03f44e3d8377f9247b0ad155 - -Since Keystone can return multiple regions in the Service Catalog, you can -specify the one you want with :option:`--os-region-name` or set the following -environment variable. (It defaults to the first in the list returned.) -:: - - export OS_REGION_NAME=region - -Ironic CLI supports bash completion. The command-line tool can automatically -fill partially typed commands. To use this feature, source the below file -(available at -https://git.openstack.org/cgit/openstack/python-ironicclient/tree/tools/ironic.bash_completion) -to your terminal and then bash completion should work:: - - $ source ironic.bash_completion - -To avoid doing this every time, add this to your ``.bashrc`` or copy the -ironic.bash_completion file to the default bash completion scripts directory -on your linux distribution. - -OPTIONS -======= - -To get a list of available (sub)commands and options, run:: - - $ ironic help - -To get usage and options of a command, run:: - - $ ironic help - - -EXAMPLES -======== - -Get information about the node-create command:: - - $ ironic help node-create - -Get a list of available drivers:: - - $ ironic driver-list - -Enroll a node with "fake" deploy driver and "ipmitool" power driver:: - - $ ironic node-create -d fake_ipmitool -i ipmi_address=1.2.3.4 - -Get a list of nodes:: - - $ ironic node-list diff --git a/doc/source/cli/index.rst b/doc/source/cli/index.rst new file mode 100644 index 000000000..51704cec1 --- /dev/null +++ b/doc/source/cli/index.rst @@ -0,0 +1,8 @@ +====================================== +python-ironicclient User Documentation +====================================== + +.. toctree:: + + standalone + osc_plugin_cli diff --git a/doc/source/cli/osc/v1/index.rst b/doc/source/cli/osc/v1/index.rst new file mode 100644 index 000000000..d5b98509c --- /dev/null +++ b/doc/source/cli/osc/v1/index.rst @@ -0,0 +1,68 @@ +Command Reference +================= + +List of released CLI commands available in openstack client. These commands +can be referenced by doing ``openstack help baremetal``. + +==================== +baremetal allocation +==================== + +.. autoprogram-cliff:: openstack.baremetal.v1 + :command: baremetal allocation * + +================= +baremetal chassis +================= + +.. autoprogram-cliff:: openstack.baremetal.v1 + :command: baremetal chassis * + +=================== +baremetal conductor +=================== + +.. autoprogram-cliff:: openstack.baremetal.v1 + :command: baremetal conductor * + +================ +baremetal create +================ + +.. autoprogram-cliff:: openstack.baremetal.v1 + :command: baremetal create + +========================= +baremetal deploy template +========================= + +.. autoprogram-cliff:: openstack.baremetal.v1 + :command: baremetal deploy template * + +================ +baremetal driver +================ + +.. autoprogram-cliff:: openstack.baremetal.v1 + :command: baremetal driver * + +============== +baremetal node +============== + +.. autoprogram-cliff:: openstack.baremetal.v1 + :command: baremetal node * + +============== +baremetal port +============== + +.. autoprogram-cliff:: openstack.baremetal.v1 + :command: baremetal port * + +================ +baremetal volume +================ + +.. autoprogram-cliff:: openstack.baremetal.v1 + :command: baremetal volume * diff --git a/doc/source/osc_plugin_cli.rst b/doc/source/cli/osc_plugin_cli.rst similarity index 65% rename from doc/source/osc_plugin_cli.rst rename to doc/source/cli/osc_plugin_cli.rst index 119bc44f0..76815fdd9 100644 --- a/doc/source/osc_plugin_cli.rst +++ b/doc/source/cli/osc_plugin_cli.rst @@ -1,6 +1,6 @@ -============================================================================ -:program:`openstack baremetal` OpenStack Client Command-Line Interface (CLI) -============================================================================ +==================================================== +``openstack baremetal`` Command-Line Interface (CLI) +==================================================== .. program:: openstack baremetal .. highlight:: bash @@ -19,14 +19,31 @@ Description The OpenStack Client plugin interacts with the Bare Metal service through the ``openstack baremetal`` command line interface (CLI). -To use ``openstack`` CLI, the OpenStackClient should be installed:: +To use the ``openstack`` CLI, the OpenStackClient (python-openstackclient) +package must be installed. There are two ways to do this: - # pip install python-openstackclient +* along with this python-ironicclient package:: + + $ pip install python-ironicclient[cli] + +* directly:: + + $ pip install python-openstackclient + +This CLI is provided by python-openstackclient and osc-lib projects: + +* https://opendev.org/openstack/python-openstackclient +* https://opendev.org/openstack/osc-lib + +.. _osc-auth: + +Authentication +-------------- To use the CLI, you must provide your OpenStack username, password, project, and auth endpoint. You can use configuration options -:option:`--os-username`, :option:`--os-password`, :option:`--os-project-id` -(or :option:`--os-project-name`), and :option:`--os-auth-url`, +``--os-username``, ``--os-password``, ``--os-project-id`` +(or ``--os-project-name``), and ``--os-auth-url``, or set the corresponding environment variables:: $ export OS_USERNAME=user @@ -37,11 +54,6 @@ or set the corresponding environment variables:: $ export OS_IDENTITY_API_VERSION=3 $ export OS_AUTH_URL=http://auth.example.com:5000/identity -This CLI is provided by python-openstackclient and osc-lib projects: - -* https://git.openstack.org/openstack/python-openstackclient -* https://git.openstack.org/openstack/osc-lib - Getting help ============ @@ -66,9 +78,9 @@ Get a list of available drivers:: $ openstack baremetal driver list -Enroll a node with "agent_ipmitool" driver:: +Enroll a node with the ``ipmi`` driver:: - $ openstack baremetal node create --driver agent_ipmitool --driver-info ipmi_address=1.2.3.4 + $ openstack baremetal node create --driver ipmi --driver-info ipmi_address=1.2.3.4 Get a list of nodes:: @@ -83,3 +95,12 @@ The baremetal API version can be specified via: * or optional command line argument --os-baremetal-api-version:: $ openstack baremetal port group list --os-baremetal-api-version 1.25 + + +Command Reference +================= +.. toctree:: + :glob: + :maxdepth: 3 + + osc/v1/* diff --git a/doc/source/cli/standalone.rst b/doc/source/cli/standalone.rst new file mode 100644 index 000000000..ef00a3b44 --- /dev/null +++ b/doc/source/cli/standalone.rst @@ -0,0 +1,81 @@ +===================================================== +``baremetal`` Standalone Command-Line Interface (CLI) +===================================================== + +.. program:: baremetal +.. highlight:: bash + +Synopsis +======== + +:program:`baremetal [options]` [command-options] + +:program:`baremetal help` + + +Description +=========== + +The standalone ``baremetal`` tool allows interacting with the Bare Metal +service without installing the OpenStack Client tool as in +:doc:`osc_plugin_cli`. + +The standalone tool is mostly identical to its OSC counterpart, with two +exceptions: + +#. No need to prefix commands with ``openstack``. +#. No authentication is assumed by default. + +Check the :doc:`OSC CLI reference ` for a list of available +commands. + +Inspector support +----------------- + +The standalone ``baremetal`` tool optionally supports the low-level bare metal +introspection API provided by ironic-inspector_. If ironic-inspector-client_ is +installed, its commands_ are automatically available (also without the +``openstack`` prefix). + +.. _ironic-inspector: https://docs.openstack.org/ironic-inspector/ +.. _ironic-inspector-client: https://docs.openstack.org/python-ironic-inspector-client/ +.. _commands: https://docs.openstack.org/python-ironic-inspector-client/latest/cli/index.html + +Standalone usage +---------------- + +To use the CLI with a standalone bare metal service, you need to provide an +endpoint to connect to. It can be done in three ways: + +#. Provide an explicit ``--os-endpoint`` argument, e.g.: + + .. code-block:: bash + + $ baremetal --os-endpoint https://ironic.host:6385 node list + +#. Set the corresponding environment variable, e.g.: + + .. code-block:: bash + + $ export OS_ENDPOINT=https://ironic.host:6385 + $ baremetal node list + +#. Populate a clouds.yaml_ file, setting ``baremetal_endpoint_override``, e.g.: + + .. code-block:: bash + + $ cat ~/.config/openstack/clouds.yaml + clouds: + ironic: + auth_type: none + baremetal_endpoint_override: http://127.0.0.1:6385 + $ export OS_CLOUD=ironic + $ baremetal node list + +.. _clouds.yaml: https://docs.openstack.org/openstacksdk/latest/user/guides/connect_from_config.html + +Usage with OpenStack +-------------------- + +The standalone CLI can also be used with the Bare Metal service installed as +part of OpenStack. See :ref:`osc-auth` for information on the required input. diff --git a/doc/source/conf.py b/doc/source/conf.py index cda54be4d..0d54eeb62 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -2,11 +2,25 @@ # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['sphinx.ext.autodoc', +extensions = ['sphinxcontrib.apidoc', 'sphinx.ext.viewcode', - 'oslosphinx', + 'openstackdocstheme', + 'cliff.sphinxext', ] +# sphinxcontrib.apidoc options +apidoc_module_dir = '../../ironicclient' +apidoc_output_dir = 'reference/api' +apidoc_excluded_paths = [ + 'tests'] +apidoc_separate_modules = True + + +# openstackdocstheme options +openstackdocs_repo_name = 'openstack/python-ironicclient' +openstackdocs_pdf_link = True +openstackdocs_use_storyboard = True + # autodoc generation is a bit aggressive and a nuisance when doing heavy # text edit cycles. # execute "export SPHINX_DEBUG=1" in your terminal to disable @@ -21,8 +35,7 @@ master_doc = 'index' # General information about the project. -project = u'python-ironicclient' -copyright = u'OpenStack Foundation' +copyright = 'OpenStack Foundation' # A list of ignored prefixes for module index sorting. modindex_common_prefix = ['ironicclient.'] @@ -35,12 +48,12 @@ add_module_names = True # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' +pygments_style = 'native' # A list of glob-style patterns that should be excluded when looking for # source files. They are matched against the source file names relative to the # source directory, using slashes as directory separators on all platforms. -exclude_patterns = ['api/ironicclient.tests.functional.*'] +exclude_patterns = [''] # -- Options for HTML output -------------------------------------------------- @@ -49,10 +62,12 @@ #html_theme_path = ["."] #html_theme = '_theme' #html_static_path = ['_static'] +html_theme = 'openstackdocs' # Output file base name for HTML help builder. -htmlhelp_basename = '%sdoc' % project +htmlhelp_basename = 'python-ironicclientdoc' +latex_use_xindy = False # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass @@ -60,9 +75,11 @@ latex_documents = [ ( 'index', - '%s.tex' % project, - u'%s Documentation' % project, - u'OpenStack LLC', + 'doc-python-ironicclient.tex', + 'Python Ironic Client Documentation', + 'OpenStack LLC', 'manual' ), ] + +autoprogram_cliff_application = 'openstack' diff --git a/doc/source/contributing.rst b/doc/source/contributing.rst deleted file mode 100644 index 18e8cab77..000000000 --- a/doc/source/contributing.rst +++ /dev/null @@ -1,62 +0,0 @@ -.. _contributing: - -=================================== -Contributing to python-ironicclient -=================================== - -If you're interested in contributing to the python-ironicclient project, -the following will help get you started. - -#openstack-ironic on Freenode IRC Network ------------------------------------------ -There is a very active chat channel at irc://freenode.net/#openstack-ironic. -This is usually the best place to ask questions and find your way around. -IRC stands for Internet Relay Chat and it is a way to chat online in real -time. You can ask a question and come back later to read the answer in the -log files. Logs for the #openstack-ironic IRC channel are stored at -http://eavesdrop.openstack.org/irclogs/%23openstack-ironic/. - -Contributor License Agreement ------------------------------ - -.. index:: - single: license; agreement - -In order to contribute to the python-ironicclient project, you need to have -signed OpenStack's contributor's agreement. - -.. seealso:: - - * http://docs.openstack.org/infra/manual/developers.html - * http://wiki.openstack.org/CLA - -LaunchPad Project ------------------ - -Most of the tools used for OpenStack depend on a launchpad.net ID for -authentication. After signing up for a launchpad account, join the -"openstack" team to have access to the mailing list and receive -notifications of important events. - -.. seealso:: - - * http://launchpad.net - * http://launchpad.net/python-ironicclient - * http://launchpad.net/~openstack - - -Project Hosting Details ------------------------ - -Bug tracker - http://launchpad.net/python-ironicclient - -Mailing list (prefix subjects with ``[ironic]`` for faster responses) - http://lists.openstack.org/cgi-bin/mailman/listinfo/openstack-dev - -Code Hosting - https://git.openstack.org/cgit/openstack/python-ironicclient - -Code Review - https://review.openstack.org/#/q/status:open+project:openstack/python-ironicclient,n,z - diff --git a/doc/source/contributor/contributing.rst b/doc/source/contributor/contributing.rst new file mode 100644 index 000000000..5d62ef7d2 --- /dev/null +++ b/doc/source/contributor/contributing.rst @@ -0,0 +1,52 @@ +.. _contributing: + +=================================== +Contributing to python-ironicclient +=================================== + +If you're interested in contributing to the python-ironicclient project, +the following will help get you started. + +#openstack-ironic on OFTC IRC Network +------------------------------------- +There is a very active chat channel at irc://irc.oftc.net/#openstack-ironic. +This is usually the best place to ask questions and find your way around. +IRC stands for Internet Relay Chat and it is a way to chat online in real +time. You can ask a question and come back later to read the answer in the +log files. Logs for the #openstack-ironic IRC channel are stored at +http://eavesdrop.openstack.org/irclogs/%23openstack-ironic/. + +Developer Certificate of Origin +------------------------------- + +.. index:: + single: license; agreement + +In order to contribute to the python-ironicclient project, you need to adhere +to the `Developer Certificate of Origin`_. OpenStack utilizes the Developer +Certificate of Origin (DCO) as a lightweight means to confirm that you are +entitled to contribute the code you submit. This ensures that you are +providing your contributions under the project's license and that you have +the right to do so. + +.. _Developer Certificate of Origin: https://developercertificate.org/ + +.. seealso:: + + * https://docs.openstack.org/contributors/common/dco.html + +Project Hosting Details +----------------------- + +Bug tracker + https://storyboard.openstack.org/#!/project/959 + +Mailing list (prefix subjects with ``[ironic]`` for faster responses) + http://lists.openstack.org/cgi-bin/mailman/listinfo/openstack-discuss + +Code Hosting + https://opendev.org/openstack/python-ironicclient + +Code Review + https://review.opendev.org/#/q/status:open+project:openstack/python-ironicclient,n,z + diff --git a/doc/source/contributor/index.rst b/doc/source/contributor/index.rst new file mode 100644 index 000000000..e0c982322 --- /dev/null +++ b/doc/source/contributor/index.rst @@ -0,0 +1,8 @@ +============================================= +python-ironicclient Contributor Documentation +============================================= + +.. toctree:: + + contributing + testing diff --git a/doc/source/contributor/testing.rst b/doc/source/contributor/testing.rst new file mode 100644 index 000000000..99090ea27 --- /dev/null +++ b/doc/source/contributor/testing.rst @@ -0,0 +1,37 @@ +.. _testing: + +======= +Testing +======= + +Python Guideline Enforcement +............................ + +All code has to pass the pep8 style guideline to merge into OpenStack, to +validate the code against these guidelines you can run:: + + $ tox -e pep8 + +Unit Testing +............ + +It is strongly encouraged to run the unit tests locally under one or more +test environments prior to submitting a patch. To run all the recommended +environments sequentially and pep8 style guideline run:: + + $ tox + +You can also selectively pick specific test environments by listing your +chosen environments after a -e flag:: + + $ tox -e py3,pep8 + +.. note:: + Tox sets up virtual environment and installs all necessary dependencies. + Sharing the environment with devstack testing is not recommended due to + conflicting configuration with system dependencies. + +Functional Testing +.................. + +Functional tests have been removed as of November 2024. diff --git a/doc/source/index.rst b/doc/source/index.rst index a7e7156d5..67dc6ee8a 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -2,23 +2,23 @@ Python Bindings to the OpenStack Ironic API =========================================== -This is a client for OpenStack `Ironic`_ API. There's a Python API -(the `ironicclient` modules), and a command-line interface (installed as -`ironic`). +This is a client for the OpenStack `Ironic`_ API. It provides: + +* a Python API: the ``ironicclient`` module, and +* command-line interface: ``openstack baremetal`` Contents ======== .. toctree:: - :maxdepth: 1 + :maxdepth: 2 api_v1 - cli - osc_plugin_cli - create_command - contributing - testing - Release Notes + cli/index + user/create_command + contributor/index + reference/index + Release Notes Indices and tables ================== diff --git a/doc/source/reference/index.rst b/doc/source/reference/index.rst new file mode 100644 index 000000000..87dea8a7c --- /dev/null +++ b/doc/source/reference/index.rst @@ -0,0 +1,8 @@ +======================================= +Full Ironic Client Python API Reference +======================================= + +.. toctree:: + :maxdepth: 1 + + api/modules diff --git a/doc/source/testing.rst b/doc/source/testing.rst deleted file mode 100644 index 97a6491c1..000000000 --- a/doc/source/testing.rst +++ /dev/null @@ -1,67 +0,0 @@ -.. _testing: - -======= -Testing -======= - -Python Guideline Enforcement -............................ - -All code has to pass the pep8 style guideline to merge into OpenStack, to -validate the code against these guidelines you can run:: - - $ tox -e pep8 - -Unit Testing -............ - -It is strongly encouraged to run the unit tests locally under one or more -test environments prior to submitting a patch. To run all the recommended -environments sequentially and pep8 style guideline run:: - - $ tox - -You can also selectively pick specific test environments by listing your -chosen environments after a -e flag:: - - $ tox -e py35,py27,pep8,pypy - -.. note:: - Tox sets up virtual environment and installs all necessary dependencies. - Sharing the environment with devstack testing is not recommended due to - conflicting configuration with system dependencies. - -Functional Testing -.................. - -Functional testing assumes the existence of the script run_functional.sh in the -python-ironicclient/tools directory. The script run_functional.sh generates -test.conf file. To run functional tests just run ./run_functional.sh. - -Also, the test.conf file could be created manually or generated from -environment variables. It assumes the existence of an openstack -cloud installation along with admin credentials. The test.conf file lives in -ironicclient/tests/functional/ directory. To run functional tests in that way -create test.conf manually and run:: - - $ tox -e functional - -An example test.conf file:: - - [functional] - api_version = 1 - os_auth_url=http://192.168.0.2:5000/v2.0/ - os_username=admin - os_password=admin - os_project_name=admin - -If you are testing ironic in standalone mode, only the parameters -'auth_strategy', 'os_auth_token' and 'ironic_url' are required; -all others will be ignored. - -An example test.conf file for standalone host:: - - [functional] - auth_strategy = noauth - os_auth_token = fake - ironic_url = http://10.0.0.2:6385 diff --git a/doc/source/create_command.rst b/doc/source/user/create_command.rst similarity index 65% rename from doc/source/create_command.rst rename to doc/source/user/create_command.rst index ec966c9f1..ce49edca4 100644 --- a/doc/source/create_command.rst +++ b/doc/source/user/create_command.rst @@ -3,58 +3,24 @@ Creating the Bare Metal service resources from file =================================================== It is possible to create a set of resources using their descriptions in JSON -or YAML format. It can be done in one of three ways: +or YAML format. It can be done in one of two ways: -1. Using ironic CLI's ``ironic create`` command:: - - $ ironic help create - usage: ironic create [ ...] - - Create baremetal resources (chassis, nodes, port groups and ports). The - resources may be described in one or more JSON or YAML files. If any file - cannot be validated, no resources are created. An attempt is made to - create all the resources; those that could not be created are skipped - (with a corresponding error message). - - Positional arguments: - File (.yaml or .json) containing descriptions of the resources - to create. Can be specified multiple times. - -2. Using openstackclient plugin command ``openstack baremetal create``:: +1. Using OpenStackClient bare metal plugin CLI's command ``openstack baremetal + create``:: $ openstack -h baremetal create - usage: openstack [-h] [-f {json,shell,table,value,yaml}] [-c COLUMN] - [--max-width ] [--noindent] [--prefix PREFIX] - [--chassis-uuid ] [--driver-info ] - [--property ] [--extra ] - [--uuid ] [--name ] - [--network-interface ] - [--resource-class ] [--driver ] - [ [ ...]] - - Create resources from files or Register a new node (DEPRECATED). Create - resources from files (by only specifying the files) or register a new - node by specifying one or more optional arguments (DEPRECATED, use - 'openstack baremetal node create' instead). + usage: openstack baremetal create [-h] [ ...] - positional arguments: - File (.yaml or .json) containing descriptions of - the resources to create. Can be specified - multiple times. If you want to create resources, - only specify the files. Do not specify any of - the optional arguments. + Create resources from files - .. note:: - If the ``--driver`` argument is passed in, the behaviour of the command - is the same as ``openstack baremetal node create``, and positional - arguments are ignored. If it is not provided, the command does resource - creation from file(s), and only positional arguments will be taken into - account. + positional arguments: + File (.yaml or .json) containing descriptions of the + resources to create. Can be specified multiple times. -3. Programmatically using the Python API: +2. Programmatically using the Python API: - .. autofunction:: ironicclient.v1.create_resources.create_resources - :noindex: + .. autofunction:: ironicclient.v1.create_resources.create_resources + :noindex: File containing Resource Descriptions ===================================== @@ -109,7 +75,7 @@ command:: "nodes": [ { "name": "node-3", - "driver": "agent_ipmitool", + "driver": "ipmi", "portgroups": [ { "name": "switch.cz7882.ports.1-2", @@ -130,11 +96,16 @@ command:: { "address": "00:00:00:00:00:03" } - ] + ], + "driver_info": { + "ipmi_address": "192.168.1.23", + "ipmi_username": "BmcUsername", + "ipmi_password": "BmcPassword", + } }, { "name": "node-4", - "driver": "agent_ipmitool", + "driver": "ipmi", "ports": [ { "address": "00:00:00:00:00:04" @@ -150,7 +121,7 @@ command:: "nodes": [ { "name": "node-5", - "driver": "pxe_ipmitool", + "driver": "ipmi", "chassis_uuid": "74d93e6e-7384-4994-a614-fd7b399b0785", "ports": [ { @@ -160,7 +131,7 @@ command:: }, { "name": "node-6", - "driver": "pxe_ipmitool" + "driver": "ipmi" } ] } diff --git a/ironicclient/client.py b/ironicclient/client.py index 74c5ae832..dbab71f1c 100644 --- a/ironicclient/client.py +++ b/ironicclient/client.py @@ -10,144 +10,126 @@ # License for the specific language governing permissions and limitations # under the License. -from keystoneauth1 import loading as kaloading +import logging + +from openstack import config from oslo_utils import importutils from ironicclient.common.i18n import _ from ironicclient import exc +LOG = logging.getLogger(__name__) + -def get_client(api_version, os_auth_token=None, ironic_url=None, - os_username=None, os_password=None, os_auth_url=None, - os_project_id=None, os_project_name=None, os_tenant_id=None, - os_tenant_name=None, os_region_name=None, - os_user_domain_id=None, os_user_domain_name=None, - os_project_domain_id=None, os_project_domain_name=None, - os_service_type=None, os_endpoint_type=None, - insecure=None, timeout=None, os_cacert=None, ca_file=None, - os_cert=None, cert_file=None, os_key=None, key_file=None, - os_ironic_api_version=None, max_retries=None, - retry_interval=None, session=None, **ignored_kwargs): +def get_client(api_version, auth_type=None, os_ironic_api_version=None, + max_retries=None, retry_interval=None, session=None, + valid_interfaces=None, interface=None, service_type=None, + region_name=None, additional_headers=None, + global_request_id=None, **kwargs): """Get an authenticated client, based on the credentials. :param api_version: the API version to use. Valid value: '1'. - :param os_auth_token: pre-existing token to re-use - :param ironic_url: ironic API endpoint - :param os_username: name of a user - :param os_password: user's password - :param os_auth_url: endpoint to authenticate against - :param os_tenant_name: name of a tenant (deprecated in favour of - os_project_name) - :param os_tenant_id: ID of a tenant (deprecated in favour of - os_project_id) - :param os_project_name: name of a project - :param os_project_id: ID of a project - :param os_region_name: name of a keystone region - :param os_user_domain_name: name of a domain the user belongs to - :param os_user_domain_id: ID of a domain the user belongs to - :param os_project_domain_name: name of a domain the project belongs to - :param os_project_domain_id: ID of a domain the project belongs to - :param os_service_type: the type of service to lookup the endpoint for - :param os_endpoint_type: the type (exposure) of the endpoint - :param insecure: allow insecure SSL (no cert verification) - :param timeout: allows customization of the timeout for client HTTP - requests - :param os_cacert: path to cacert file - :param ca_file: path to cacert file, deprecated in favour of os_cacert - :param os_cert: path to cert file - :param cert_file: path to cert file, deprecated in favour of os_cert - :param os_key: path to key file - :param key_file: path to key file, deprecated in favour of os_key - :param os_ironic_api_version: ironic API version to use + :param auth_type: type of keystoneauth auth plugin loader to use. + :param os_ironic_api_version: ironic API version to use. :param max_retries: Maximum number of retries in case of conflict error :param retry_interval: Amount of time (in seconds) between retries in case - of conflict error - :param session: Keystone session to use - :param ignored_kwargs: all the other params that are passed. Left for - backwards compatibility. They are ignored. + of conflict error. + :param session: An existing keystoneauth session. Will be created from + kwargs if not provided. + :param valid_interfaces: List of valid endpoint interfaces to use if + the bare metal endpoint is not provided. + :param interface: An alias for valid_interfaces. + :param service_type: Bare metal endpoint service type. + :param region_name: Name of the region to use when searching the bare metal + endpoint. + :param additional_headers: Additional headers that should be attached + to every request passing through the client. Headers of the same name + specified per request will take priority. + :param global_request_id: A header (in the form of ``req-$uuid``) that will + be passed on all requests. Enables cross project request id tracking. + :param kwargs: all the other params that are passed to keystoneauth for + session construction. """ - os_service_type = os_service_type or 'baremetal' - os_endpoint_type = os_endpoint_type or 'publicURL' - project_id = (os_project_id or os_tenant_id) - project_name = (os_project_name or os_tenant_name) - kwargs = { + # TODO(TheJulia): At some point, we should consider possibly noting + # the "latest" flag for os_ironic_api_version to cause the client to + # auto-negotiate to the greatest available version, however we do not + # have the ability yet for a caller to cap the version, and will hold + # off doing so until then. + if auth_type is None: + if 'endpoint' in kwargs: + if 'token' in kwargs: + auth_type = 'admin_token' + else: + auth_type = 'none' + elif 'token' in kwargs and 'auth_url' in kwargs: + auth_type = 'token' + else: + auth_type = 'password' + + if not session: + # TODO(dtantsur): consider flipping load_yaml_config to True to support + # the clouds.yaml format. + region = config.get_cloud_region(load_yaml_config=False, + load_envvars=False, + auth_type=auth_type, + **kwargs) + session = region.get_session() + + # Make sure we also pass the endpoint interface to the HTTP client. + # NOTE(gyee/efried): 'interface' in ksa config is deprecated in favor of + # 'valid_interfaces'. So, since the caller may be deriving kwargs from + # conf, accept 'valid_interfaces' first. But keep support for 'interface', + # in case the caller is deriving kwargs from, say, an existing Adapter. + interface = valid_interfaces or interface + + endpoint = kwargs.get('endpoint') + if not endpoint: + try: + # endpoint will be used to get hostname + # and port that will be used for API version caching. + # NOTE(gyee): KSA defaults interface to 'public' if it is + # empty or None so there's no need to set it to publicURL + # explicitly. + endpoint = session.get_endpoint( + service_type=service_type or 'baremetal', + interface=interface, + region_name=region_name, + ) + except Exception as e: + raise exc.AmbiguousAuthSystem( + _('Must provide Keystone credentials or user-defined ' + 'endpoint, error was: %s') % e) + + ironicclient_kwargs = { 'os_ironic_api_version': os_ironic_api_version, + 'additional_headers': additional_headers, + 'global_request_id': global_request_id, 'max_retries': max_retries, 'retry_interval': retry_interval, + 'session': session, + 'endpoint_override': endpoint, + 'interface': interface } - endpoint = ironic_url - cacert = os_cacert or ca_file - cert = os_cert or cert_file - key = os_key or key_file - if os_auth_token and endpoint: - kwargs.update({ - 'token': os_auth_token, - 'insecure': insecure, - 'ca_file': cacert, - 'cert_file': cert, - 'key_file': key, - 'timeout': timeout, - }) - elif os_auth_url: - auth_type = 'password' - auth_kwargs = { - 'auth_url': os_auth_url, - 'project_id': project_id, - 'project_name': project_name, - 'user_domain_id': os_user_domain_id, - 'user_domain_name': os_user_domain_name, - 'project_domain_id': os_project_domain_id, - 'project_domain_name': os_project_domain_name, - } - if os_username and os_password: - auth_kwargs.update({ - 'username': os_username, - 'password': os_password, - }) - elif os_auth_token: - auth_type = 'token' - auth_kwargs.update({ - 'token': os_auth_token, - }) - # Create new session only if it was not passed in - if not session: - loader = kaloading.get_plugin_loader(auth_type) - auth_plugin = loader.load_from_options(**auth_kwargs) - # Let keystoneauth do the necessary parameter conversions - session = kaloading.session.Session().load_from_options( - auth=auth_plugin, insecure=insecure, cacert=cacert, - cert=cert, key=key, timeout=timeout, - ) - exception_msg = _('Must provide Keystone credentials or user-defined ' - 'endpoint and token') - if not endpoint: - if session: - try: - # Pass the endpoint, it will be used to get hostname - # and port that will be used for API version caching. It will - # be also set as endpoint_override. - endpoint = session.get_endpoint( - service_type=os_service_type, - interface=os_endpoint_type, - region_name=os_region_name - ) - except Exception as e: - raise exc.AmbiguousAuthSystem( - _('%(message)s, error was: %(error)s') % - {'message': exception_msg, 'error': e}) - else: - # Neither session, nor valid auth parameters provided - raise exc.AmbiguousAuthSystem(exception_msg) + return Client(api_version, **ironicclient_kwargs) - # Always pass the session - kwargs['session'] = session - return Client(api_version, endpoint, **kwargs) +def Client(version, endpoint_override=None, session=None, *args, **kwargs): + """Create a client of an appropriate version. + This call requires a session. If you want it to be created, use + ``get_client`` instead. -def Client(version, *args, **kwargs): + :param endpoint_override: A bare metal endpoint to use. + :param session: A keystoneauth session to use. This argument is actually + required and is marked optional only for backward compatibility. + :param args: Other arguments to pass to the HTTP client. Not recommended, + use kwargs instead. + :param kwargs: Other keyword arguments to pass to the HTTP client (e.g. + ``insecure``). + """ module = importutils.import_versioned_module('ironicclient', version, 'client') client_class = getattr(module, 'Client') - return client_class(*args, **kwargs) + return client_class(endpoint_override=endpoint_override, session=session, + *args, **kwargs) diff --git a/ironicclient/common/apiclient/base.py b/ironicclient/common/apiclient/base.py index fcf536d0c..b153de305 100644 --- a/ironicclient/common/apiclient/base.py +++ b/ironicclient/common/apiclient/base.py @@ -20,23 +20,28 @@ Base utilities to build API operation managers and objects on top of. """ +from __future__ import annotations # E1102: %s is not callable # pylint: disable=E1102 import abc +from collections.abc import Sequence import copy +from http import client as http_client +from typing import Any +from typing import Callable +from typing import cast +from typing import ClassVar +from urllib import parse as urlparse from oslo_utils import strutils -import six -from six.moves import http_client -from six.moves.urllib import parse from ironicclient.common.apiclient import exceptions from ironicclient.common.i18n import _ -def getid(obj): +def getid(obj: Any) -> Any: """Return id if argument is a Resource. Abstracts the common pattern of allowing both an object or an object's ID @@ -56,10 +61,11 @@ def getid(obj): # TODO(aababilov): call run_hooks() in HookableMixin's child classes class HookableMixin(object): """Mixin so classes can register and run hooks.""" - _hooks_map = {} + + _hooks_map: ClassVar[dict[str, list[Callable[..., Any]]]] = {} @classmethod - def add_hook(cls, hook_type, hook_func): + def add_hook(cls, hook_type: str, hook_func: Callable[..., Any]) -> None: """Add a new hook of specified type. :param cls: class that registers hooks @@ -72,7 +78,7 @@ def add_hook(cls, hook_type, hook_func): cls._hooks_map[hook_type].append(hook_func) @classmethod - def run_hooks(cls, hook_type, *args, **kwargs): + def run_hooks(cls, hook_type: str, *args: Any, **kwargs: Any) -> None: """Run all hooks of specified type. :param cls: class that registers hooks @@ -91,9 +97,10 @@ class BaseManager(HookableMixin): Managers interact with a particular type of API (servers, flavors, images, etc.) and provide CRUD operations for them. """ - resource_class = None - def __init__(self, client): + resource_class: type[Resource] | None = None + + def __init__(self, client: Any) -> None: """Initializes BaseManager with `client`. :param client: instance of BaseClient descendant for HTTP requests @@ -101,7 +108,13 @@ def __init__(self, client): super(BaseManager, self).__init__() self.client = client - def _list(self, url, response_key=None, obj_class=None, json=None): + def _list( + self, + url: str, + response_key: str | None = None, + obj_class: type[Resource] | None = None, + json: dict[str, Any] | None = None, + ) -> list[Resource]: """List the collection. :param url: a partial URL, e.g., '/servers' @@ -121,17 +134,20 @@ def _list(self, url, response_key=None, obj_class=None, json=None): if obj_class is None: obj_class = self.resource_class + if obj_class is None: + raise ValueError("resource_class must be set") + data = body[response_key] if response_key is not None else body # NOTE(ja): keystone returns values as list as {'values': [ ... ]} # unlike other services which just return the list... try: - data = data['values'] + data = data["values"] except (KeyError, TypeError): pass return [obj_class(self, res, loaded=True) for res in data if res] - def _get(self, url, response_key=None): + def _get(self, url: str, response_key: str | None = None) -> Resource: """Get an object from collection. :param url: a partial URL, e.g., '/servers' @@ -139,19 +155,27 @@ def _get(self, url, response_key=None): e.g., 'server'. If response_key is None - all response body will be used. """ + if self.resource_class is None: + raise ValueError("resource_class must be set") body = self.client.get(url).json() data = body[response_key] if response_key is not None else body return self.resource_class(self, data, loaded=True) - def _head(self, url): + def _head(self, url: str) -> bool: """Retrieve request headers for an object. :param url: a partial URL, e.g., '/servers' """ resp = self.client.head(url) - return resp.status_code == http_client.NO_CONTENT - - def _post(self, url, json, response_key=None, return_raw=False): + return bool(resp.status_code == http_client.NO_CONTENT) + + def _post( + self, + url: str, + json: dict[str, Any], + response_key: str | None = None, + return_raw: bool = False, + ) -> dict[str, Any] | Resource: """Create an object. :param url: a partial URL, e.g., '/servers' @@ -163,13 +187,20 @@ def _post(self, url, json, response_key=None, return_raw=False): :param return_raw: flag to force returning raw JSON instead of Python object of self.resource_class """ + if self.resource_class is None: + raise ValueError("resource_class must be set") body = self.client.post(url, json=json).json() data = body[response_key] if response_key is not None else body if return_raw: - return data + return cast(dict[str, Any], data) return self.resource_class(self, data) - def _put(self, url, json=None, response_key=None): + def _put( + self, + url: str, + json: dict[str, Any] | None = None, + response_key: str | None = None, + ) -> Resource | None: """Update an object with PUT method. :param url: a partial URL, e.g., '/servers' @@ -179,6 +210,8 @@ def _put(self, url, json=None, response_key=None): e.g., 'servers'. If response_key is None - all response body will be used. """ + if self.resource_class is None: + raise ValueError("resource_class must be set") resp = self.client.put(url, json=json) # PUT requests may not return a body if resp.content: @@ -187,8 +220,14 @@ def _put(self, url, json=None, response_key=None): return self.resource_class(self, body[response_key]) else: return self.resource_class(self, body) + return None - def _patch(self, url, json=None, response_key=None): + def _patch( + self, + url: str, + json: dict[str, Any] | None = None, + response_key: str | None = None, + ) -> Resource: """Update an object with PATCH method. :param url: a partial URL, e.g., '/servers' @@ -198,13 +237,15 @@ def _patch(self, url, json=None, response_key=None): e.g., 'servers'. If response_key is None - all response body will be used. """ + if self.resource_class is None: + raise ValueError("resource_class must be set") body = self.client.patch(url, json=json).json() if response_key is not None: return self.resource_class(self, body[response_key]) else: return self.resource_class(self, body) - def _delete(self, url): + def _delete(self, url: str) -> Any: """Delete an object. :param url: a partial URL, e.g., '/servers/my-server' @@ -212,26 +253,27 @@ def _delete(self, url): return self.client.delete(url) -@six.add_metaclass(abc.ABCMeta) -class ManagerWithFind(BaseManager): +class ManagerWithFind(BaseManager, metaclass=abc.ABCMeta): """Manager with additional `find()`/`findall()` methods.""" @abc.abstractmethod - def list(self): + def list(self) -> list[Resource]: pass - def find(self, **kwargs): + def find(self, **kwargs: Any) -> Resource: """Find a single item with attributes matching ``**kwargs``. This isn't very efficient: it loads the entire list then filters on the Python side. """ + if self.resource_class is None: + raise ValueError("resource_class must be set") matches = self.findall(**kwargs) num_matches = len(matches) if num_matches == 0: msg = _("No %(name)s matching %(args)s.") % { - 'name': self.resource_class.__name__, - 'args': kwargs + "name": self.resource_class.__name__, + "args": kwargs, } raise exceptions.NotFound(msg) elif num_matches > 1: @@ -239,19 +281,22 @@ def find(self, **kwargs): else: return matches[0] - def findall(self, **kwargs): + def findall(self, **kwargs: Any) -> Sequence[Resource]: """Find all items with attributes matching ``**kwargs``. This isn't very efficient: it loads the entire list then filters on the Python side. """ - found = [] + found: list[Resource] = [] searches = kwargs.items() - for obj in self.list(): + items = self.list() + for obj in items: try: - if all(getattr(obj, attr) == value - for (attr, value) in searches): + if all( + getattr(obj, attr) == value + for (attr, value) in searches + ): found.append(obj) except AttributeError: continue @@ -272,10 +317,11 @@ class CrudManager(BaseManager): refer to an individual member of the collection. """ - collection_key = None - key = None - def build_url(self, base_url=None, **kwargs): + collection_key: str | None = None + key: str | None = None + + def build_url(self, base_url: str | None = None, **kwargs: Any) -> str: """Builds a resource URL for the given kwargs. Given an example collection where `collection_key = 'entities'` and @@ -292,18 +338,18 @@ def build_url(self, base_url=None, **kwargs): :param base_url: if provided, the generated URL will be appended to it """ - url = base_url if base_url is not None else '' + url = base_url if base_url is not None else "" - url += '/%s' % self.collection_key + url += "/%s" % self.collection_key # do we have a specific entity? - entity_id = kwargs.get('%s_id' % self.key) + entity_id = kwargs.get("%s_id" % self.key) if entity_id is not None: - url += '/%s' % entity_id + url += "/%s" % entity_id return url - def _filter_kwargs(self, kwargs): + def _filter_kwargs(self, kwargs: dict[str, Any]) -> dict[str, Any]: """Drop null values and handle ids.""" for key, ref in kwargs.copy().items(): if ref is None: @@ -311,27 +357,33 @@ def _filter_kwargs(self, kwargs): else: if isinstance(ref, Resource): kwargs.pop(key) - kwargs['%s_id' % key] = getid(ref) + kwargs["%s_id" % key] = getid(ref) return kwargs - def create(self, **kwargs): + def create(self, **kwargs: Any) -> Resource: + if self.key is None: + raise ValueError("key must be set") kwargs = self._filter_kwargs(kwargs) - return self._post( + result = self._post( self.build_url(**kwargs), {self.key: kwargs}, - self.key) + self.key, + ) + if isinstance(result, dict): + raise ValueError("Expected Resource, got dict") + return result - def get(self, **kwargs): + def get(self, **kwargs: Any) -> Resource: kwargs = self._filter_kwargs(kwargs) - return self._get( - self.build_url(**kwargs), - self.key) + return self._get(self.build_url(**kwargs), self.key) - def head(self, **kwargs): + def head(self, **kwargs: Any) -> bool: kwargs = self._filter_kwargs(kwargs) return self._head(self.build_url(**kwargs)) - def list(self, base_url=None, **kwargs): + def list( + self, base_url: str | None = None, **kwargs: Any + ) -> list[Resource]: """List the collection. :param base_url: if provided, the generated URL will be appended to it @@ -339,13 +391,20 @@ def list(self, base_url=None, **kwargs): kwargs = self._filter_kwargs(kwargs) return self._list( - '%(base_url)s%(query)s' % { - 'base_url': self.build_url(base_url=base_url, **kwargs), - 'query': '?%s' % parse.urlencode(kwargs) if kwargs else '', + "%(base_url)s%(query)s" + % { + "base_url": self.build_url(base_url=base_url, **kwargs), + "query": ( + "?%s" % urlparse.urlencode(kwargs) + if kwargs else "" + ), }, - self.collection_key) + self.collection_key, + ) - def put(self, base_url=None, **kwargs): + def put( + self, base_url: str | None = None, **kwargs: Any + ) -> Resource | None: """Update an element. :param base_url: if provided, the generated URL will be appended to it @@ -354,41 +413,47 @@ def put(self, base_url=None, **kwargs): return self._put(self.build_url(base_url=base_url, **kwargs)) - def update(self, **kwargs): + def update(self, **kwargs: Any) -> Resource: kwargs = self._filter_kwargs(kwargs) params = kwargs.copy() - params.pop('%s_id' % self.key) + if self.key is None: + raise ValueError("key must be set") + params.pop("%s_id" % self.key) return self._patch( self.build_url(**kwargs), {self.key: params}, - self.key) + self.key, + ) - def delete(self, **kwargs): + def delete(self, **kwargs: Any) -> Any: kwargs = self._filter_kwargs(kwargs) - return self._delete( - self.build_url(**kwargs)) + return self._delete(self.build_url(**kwargs)) - def find(self, base_url=None, **kwargs): + def find(self, base_url: str | None = None, **kwargs: Any) -> Resource: """Find a single item with attributes matching ``**kwargs``. :param base_url: if provided, the generated URL will be appended to it """ + if self.resource_class is None: + raise ValueError("resource_class must be set") kwargs = self._filter_kwargs(kwargs) rl = self._list( - '%(base_url)s%(query)s' % { - 'base_url': self.build_url(base_url=base_url, **kwargs), - 'query': '?%s' % parse.urlencode(kwargs) if kwargs else '', + "%(base_url)s%(query)s" + % { + "base_url": self.build_url(base_url=base_url, **kwargs), + "query": "?%s" % urlparse.urlencode(kwargs) if kwargs else "", }, - self.collection_key) + self.collection_key, + ) num = len(rl) if num == 0: msg = _("No %(name)s matching %(args)s.") % { - 'name': self.resource_class.__name__, - 'args': kwargs + "name": self.resource_class.__name__, + "args": kwargs, } raise exceptions.NotFound(msg) elif num > 1: @@ -400,16 +465,18 @@ def find(self, base_url=None, **kwargs): class Extension(HookableMixin): """Extension descriptor.""" - SUPPORTED_HOOKS = ('__pre_parse_args__', '__post_parse_args__') - manager_class = None + SUPPORTED_HOOKS: tuple[str, ...] = ( + "__pre_parse_args__", "__post_parse_args__" + ) + manager_class: type[BaseManager] | None = None - def __init__(self, name, module): + def __init__(self, name: str, module: Any) -> None: super(Extension, self).__init__() self.name = name self.module = module self._parse_extension_module() - def _parse_extension_module(self): + def _parse_extension_module(self) -> None: self.manager_class = None for attr_name, attr_value in self.module.__dict__.items(): if attr_name in self.SUPPORTED_HOOKS: @@ -421,7 +488,7 @@ def _parse_extension_module(self): except TypeError: pass - def __repr__(self): + def __repr__(self) -> str: return "" % self.name @@ -431,10 +498,12 @@ class Resource(object): This is pretty much just a bag for attributes. """ - HUMAN_ID = False - NAME_ATTR = 'name' + HUMAN_ID: bool = False + NAME_ATTR: str = "name" - def __init__(self, manager, info, loaded=False): + def __init__( + self, manager: BaseManager, info: dict[str, Any], loaded: bool = False + ) -> None: """Populate and bind to a manager. :param manager: BaseManager object @@ -446,24 +515,24 @@ def __init__(self, manager, info, loaded=False): self._add_details(info) self._loaded = loaded - def __repr__(self): - reprkeys = sorted(k - for k in self.__dict__.keys() - if k[0] != '_' and k != 'manager') + def __repr__(self) -> str: + reprkeys = sorted( + k for k in self.__dict__.keys() if k[0] != "_" and k != "manager" + ) info = ", ".join("%s=%s" % (k, getattr(self, k)) for k in reprkeys) return "<%s %s>" % (self.__class__.__name__, info) @property - def human_id(self): + def human_id(self) -> str | None: """Human-readable ID which can be used for bash completion.""" if self.HUMAN_ID: name = getattr(self, self.NAME_ATTR, None) if name is not None: - return strutils.to_slug(name) + return str(strutils.to_slug(name)) return None - def _add_details(self, info): - for (k, v) in info.items(): + def _add_details(self, info: dict[str, Any]) -> None: + for k, v in info.items(): try: setattr(self, k, v) self._info[k] = v @@ -471,7 +540,7 @@ def _add_details(self, info): # In this case we already defined the attribute on the class pass - def __getattr__(self, k): + def __getattr__(self, k: str) -> Any: if k not in self.__dict__: # NOTE(bcwaldon): disallow lazy-loading if already loaded once if not self.is_loaded(): @@ -482,7 +551,7 @@ def __getattr__(self, k): else: return self.__dict__[k] - def get(self): + def get(self) -> None: """Support for lazy loading details. Some clients, such as novaclient have the option to lazy load the @@ -490,16 +559,17 @@ def get(self): """ # set_loaded() first ... so if we have to bail, we know we tried. self.set_loaded(True) - if not hasattr(self.manager, 'get'): + if not hasattr(self.manager, "get"): return new = self.manager.get(self.id) if new: self._add_details(new._info) self._add_details( - {'x_request_id': self.manager.client.last_request_id}) + {"x_request_id": self.manager.client.last_request_id} + ) - def __eq__(self, other): + def __eq__(self, other: object) -> bool: if not isinstance(other, Resource): return NotImplemented # two resources of different types are not equal @@ -507,11 +577,11 @@ def __eq__(self, other): return False return self._info == other._info - def is_loaded(self): + def is_loaded(self) -> bool: return self._loaded - def set_loaded(self, val): + def set_loaded(self, val: bool) -> None: self._loaded = val - def to_dict(self): + def to_dict(self) -> dict[str, Any]: return copy.deepcopy(self._info) diff --git a/ironicclient/common/apiclient/exceptions.py b/ironicclient/common/apiclient/exceptions.py index 81a3340a2..a0a863ed9 100644 --- a/ironicclient/common/apiclient/exceptions.py +++ b/ironicclient/common/apiclient/exceptions.py @@ -20,12 +20,15 @@ Exception definitions. """ +from __future__ import annotations +from http import client as http_client import inspect import sys +from typing import Any +from typing import cast -import six -from six.moves import http_client +import requests # type: ignore[import-untyped] from ironicclient.common.i18n import _ @@ -67,7 +70,7 @@ class ConnectionRefused(ConnectionError): class AuthPluginOptionsMissing(AuthorizationFailure): """Auth plugin misses some options.""" - def __init__(self, opt_names): + def __init__(self, opt_names: list[str]) -> None: super(AuthPluginOptionsMissing, self).__init__( _("Authentication failed. Missing options: %s") % ", ".join(opt_names)) @@ -76,7 +79,7 @@ def __init__(self, opt_names): class AuthSystemNotFound(AuthorizationFailure): """User has specified an AuthSystem that is not installed.""" - def __init__(self, auth_system): + def __init__(self, auth_system: str) -> None: super(AuthSystemNotFound, self).__init__( _("AuthSystemNotFound: %r") % auth_system) self.auth_system = auth_system @@ -99,7 +102,7 @@ class EndpointNotFound(EndpointException): class AmbiguousEndpoints(EndpointException): """Found more than one matching endpoint in Service Catalog.""" - def __init__(self, endpoints=None): + def __init__(self, endpoints: object | None = None) -> None: super(AmbiguousEndpoints, self).__init__( _("AmbiguousEndpoints: %r") % endpoints) self.endpoints = endpoints @@ -107,12 +110,19 @@ def __init__(self, endpoints=None): class HttpError(ClientException): """The base exception class for all HTTP exceptions.""" - http_status = 0 - message = _("HTTP Error") - - def __init__(self, message=None, details=None, - response=None, request_id=None, - url=None, method=None, http_status=None): + http_status: int = 0 + message: str = _("HTTP Error") + + def __init__( + self, + message: str | None = None, + details: str | None = None, + response: requests.Response | None = None, + request_id: str | None = None, + url: str | None = None, + method: str | None = None, + http_status: int | None = None, + ) -> None: self.http_status = http_status or self.http_status self.message = message or self.message self.details = details @@ -128,7 +138,7 @@ def __init__(self, message=None, details=None, class HTTPRedirection(HttpError): """HTTP Redirection.""" - message = _("HTTP Redirection") + message: str = _("HTTP Redirection") class HTTPClientError(HttpError): @@ -136,7 +146,7 @@ class HTTPClientError(HttpError): Exception for cases in which the client seems to have erred. """ - message = _("HTTP Client Error") + message: str = _("HTTP Client Error") class HttpServerError(HttpError): @@ -145,7 +155,7 @@ class HttpServerError(HttpError): Exception for cases in which the server is aware that it has erred or is incapable of performing the request. """ - message = _("HTTP Server Error") + message: str = _("HTTP Server Error") class MultipleChoices(HTTPRedirection): @@ -154,8 +164,8 @@ class MultipleChoices(HTTPRedirection): Indicates multiple options for the resource that the client may follow. """ - http_status = http_client.MULTIPLE_CHOICES - message = _("Multiple Choices") + http_status: int = http_client.MULTIPLE_CHOICES + message: str = _("Multiple Choices") class BadRequest(HTTPClientError): @@ -163,8 +173,8 @@ class BadRequest(HTTPClientError): The request cannot be fulfilled due to bad syntax. """ - http_status = http_client.BAD_REQUEST - message = _("Bad Request") + http_status: int = http_client.BAD_REQUEST + message: str = _("Bad Request") class Unauthorized(HTTPClientError): @@ -173,8 +183,8 @@ class Unauthorized(HTTPClientError): Similar to 403 Forbidden, but specifically for use when authentication is required and has failed or has not yet been provided. """ - http_status = http_client.UNAUTHORIZED - message = _("Unauthorized") + http_status: int = http_client.UNAUTHORIZED + message: str = _("Unauthorized") class PaymentRequired(HTTPClientError): @@ -182,8 +192,8 @@ class PaymentRequired(HTTPClientError): Reserved for future use. """ - http_status = http_client.PAYMENT_REQUIRED - message = _("Payment Required") + http_status: int = http_client.PAYMENT_REQUIRED + message: str = _("Payment Required") class Forbidden(HTTPClientError): @@ -192,8 +202,8 @@ class Forbidden(HTTPClientError): The request was a valid request, but the server is refusing to respond to it. """ - http_status = http_client.FORBIDDEN - message = _("Forbidden") + http_status: int = http_client.FORBIDDEN + message: str = _("Forbidden") class NotFound(HTTPClientError): @@ -202,8 +212,8 @@ class NotFound(HTTPClientError): The requested resource could not be found but may be available again in the future. """ - http_status = http_client.NOT_FOUND - message = _("Not Found") + http_status: int = http_client.NOT_FOUND + message: str = _("Not Found") class MethodNotAllowed(HTTPClientError): @@ -212,8 +222,8 @@ class MethodNotAllowed(HTTPClientError): A request was made of a resource using a request method not supported by that resource. """ - http_status = http_client.METHOD_NOT_ALLOWED - message = _("Method Not Allowed") + http_status: int = http_client.METHOD_NOT_ALLOWED + message: str = _("Method Not Allowed") class NotAcceptable(HTTPClientError): @@ -222,8 +232,8 @@ class NotAcceptable(HTTPClientError): The requested resource is only capable of generating content not acceptable according to the Accept headers sent in the request. """ - http_status = http_client.NOT_ACCEPTABLE - message = _("Not Acceptable") + http_status: int = http_client.NOT_ACCEPTABLE + message: str = _("Not Acceptable") class ProxyAuthenticationRequired(HTTPClientError): @@ -231,8 +241,8 @@ class ProxyAuthenticationRequired(HTTPClientError): The client must first authenticate itself with the proxy. """ - http_status = http_client.PROXY_AUTHENTICATION_REQUIRED - message = _("Proxy Authentication Required") + http_status: int = http_client.PROXY_AUTHENTICATION_REQUIRED + message: str = _("Proxy Authentication Required") class RequestTimeout(HTTPClientError): @@ -240,8 +250,8 @@ class RequestTimeout(HTTPClientError): The server timed out waiting for the request. """ - http_status = http_client.REQUEST_TIMEOUT - message = _("Request Timeout") + http_status: int = http_client.REQUEST_TIMEOUT + message: str = _("Request Timeout") class Conflict(HTTPClientError): @@ -250,8 +260,8 @@ class Conflict(HTTPClientError): Indicates that the request could not be processed because of conflict in the request, such as an edit conflict. """ - http_status = http_client.CONFLICT - message = _("Conflict") + http_status: int = http_client.CONFLICT + message: str = _("Conflict") class Gone(HTTPClientError): @@ -260,8 +270,8 @@ class Gone(HTTPClientError): Indicates that the resource requested is no longer available and will not be available again. """ - http_status = http_client.GONE - message = _("Gone") + http_status: int = http_client.GONE + message: str = _("Gone") class LengthRequired(HTTPClientError): @@ -270,8 +280,8 @@ class LengthRequired(HTTPClientError): The request did not specify the length of its content, which is required by the requested resource. """ - http_status = http_client.LENGTH_REQUIRED - message = _("Length Required") + http_status: int = http_client.LENGTH_REQUIRED + message: str = _("Length Required") class PreconditionFailed(HTTPClientError): @@ -280,8 +290,8 @@ class PreconditionFailed(HTTPClientError): The server does not meet one of the preconditions that the requester put on the request. """ - http_status = http_client.PRECONDITION_FAILED - message = _("Precondition Failed") + http_status: int = http_client.PRECONDITION_FAILED + message: str = _("Precondition Failed") class RequestEntityTooLarge(HTTPClientError): @@ -289,10 +299,10 @@ class RequestEntityTooLarge(HTTPClientError): The request is larger than the server is willing or able to process. """ - http_status = http_client.REQUEST_ENTITY_TOO_LARGE - message = _("Request Entity Too Large") + http_status: int = http_client.REQUEST_ENTITY_TOO_LARGE + message: str = _("Request Entity Too Large") - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any) -> None: try: self.retry_after = int(kwargs.pop('retry_after')) except (KeyError, ValueError): @@ -306,8 +316,8 @@ class RequestUriTooLong(HTTPClientError): The URI provided was too long for the server to process. """ - http_status = http_client.REQUEST_URI_TOO_LONG - message = _("Request-URI Too Long") + http_status: int = http_client.REQUEST_URI_TOO_LONG + message: str = _("Request-URI Too Long") class UnsupportedMediaType(HTTPClientError): @@ -316,8 +326,8 @@ class UnsupportedMediaType(HTTPClientError): The request entity has a media type which the server or resource does not support. """ - http_status = http_client.UNSUPPORTED_MEDIA_TYPE - message = _("Unsupported Media Type") + http_status: int = http_client.UNSUPPORTED_MEDIA_TYPE + message: str = _("Unsupported Media Type") class RequestedRangeNotSatisfiable(HTTPClientError): @@ -326,8 +336,8 @@ class RequestedRangeNotSatisfiable(HTTPClientError): The client has asked for a portion of the file, but the server cannot supply that portion. """ - http_status = http_client.REQUESTED_RANGE_NOT_SATISFIABLE - message = _("Requested Range Not Satisfiable") + http_status: int = http_client.REQUESTED_RANGE_NOT_SATISFIABLE + message: str = _("Requested Range Not Satisfiable") class ExpectationFailed(HTTPClientError): @@ -335,8 +345,8 @@ class ExpectationFailed(HTTPClientError): The server cannot meet the requirements of the Expect request-header field. """ - http_status = http_client.EXPECTATION_FAILED - message = _("Expectation Failed") + http_status: int = http_client.EXPECTATION_FAILED + message: str = _("Expectation Failed") class UnprocessableEntity(HTTPClientError): @@ -345,8 +355,8 @@ class UnprocessableEntity(HTTPClientError): The request was well-formed but was unable to be followed due to semantic errors. """ - http_status = http_client.UNPROCESSABLE_ENTITY - message = _("Unprocessable Entity") + http_status: int = http_client.UNPROCESSABLE_ENTITY + message: str = _("Unprocessable Entity") class InternalServerError(HttpServerError): @@ -354,8 +364,8 @@ class InternalServerError(HttpServerError): A generic error message, given when no more specific message is suitable. """ - http_status = http_client.INTERNAL_SERVER_ERROR - message = _("Internal Server Error") + http_status: int = http_client.INTERNAL_SERVER_ERROR + message: str = _("Internal Server Error") # NotImplemented is a python keyword. @@ -365,8 +375,8 @@ class HttpNotImplemented(HttpServerError): The server either does not recognize the request method, or it lacks the ability to fulfill the request. """ - http_status = http_client.NOT_IMPLEMENTED - message = _("Not Implemented") + http_status: int = http_client.NOT_IMPLEMENTED + message: str = _("Not Implemented") class BadGateway(HttpServerError): @@ -375,8 +385,8 @@ class BadGateway(HttpServerError): The server was acting as a gateway or proxy and received an invalid response from the upstream server. """ - http_status = http_client.BAD_GATEWAY - message = _("Bad Gateway") + http_status: int = http_client.BAD_GATEWAY + message: str = _("Bad Gateway") class ServiceUnavailable(HttpServerError): @@ -384,8 +394,8 @@ class ServiceUnavailable(HttpServerError): The server is currently unavailable. """ - http_status = http_client.SERVICE_UNAVAILABLE - message = _("Service Unavailable") + http_status: int = http_client.SERVICE_UNAVAILABLE + message: str = _("Service Unavailable") class GatewayTimeout(HttpServerError): @@ -394,8 +404,8 @@ class GatewayTimeout(HttpServerError): The server was acting as a gateway or proxy and did not receive a timely response from the upstream server. """ - http_status = http_client.GATEWAY_TIMEOUT - message = _("Gateway Timeout") + http_status: int = http_client.GATEWAY_TIMEOUT + message: str = _("Gateway Timeout") class HttpVersionNotSupported(HttpServerError): @@ -403,19 +413,21 @@ class HttpVersionNotSupported(HttpServerError): The server does not support the HTTP protocol version used in the request. """ - http_status = http_client.HTTP_VERSION_NOT_SUPPORTED - message = _("HTTP Version Not Supported") + http_status: int = http_client.HTTP_VERSION_NOT_SUPPORTED + message: str = _("HTTP Version Not Supported") # _code_map contains all the classes that have http_status attribute. -_code_map = dict( - (getattr(obj, 'http_status', None), obj) +_code_map: dict[int, type[HttpError]] = { + cast(int, getattr(obj, 'http_status')): cast(type[HttpError], obj) for name, obj in vars(sys.modules[__name__]).items() if inspect.isclass(obj) and getattr(obj, 'http_status', False) -) +} -def from_response(response, method, url): +def from_response( + response: requests.Response, method: str, url: str +) -> HttpError: """Returns an instance of :class:`HttpError` or subclass based on response. :param response: instance of `requests.Response` class @@ -427,7 +439,7 @@ def from_response(response, method, url): # NOTE(hdd) true for older versions of nova and cinder if not req_id: req_id = response.headers.get("x-compute-request-id") - kwargs = { + kwargs: dict[str, Any] = { "http_status": response.status_code, "response": response, "method": method, @@ -447,13 +459,14 @@ def from_response(response, method, url): if isinstance(body, dict): error = body.get(list(body)[0]) if isinstance(error, dict): - kwargs["message"] = (error.get("message") or - error.get("faultstring")) - kwargs["details"] = (error.get("details") or - six.text_type(body)) + kwargs["message"] = (error.get("message") + or error.get("faultstring")) + kwargs["details"] = (error.get("details") + or str(body)) elif content_type.startswith("text/"): kwargs["details"] = getattr(response, 'text', '') + cls: type[HttpError] try: cls = _code_map[response.status_code] except KeyError: @@ -461,8 +474,8 @@ def from_response(response, method, url): if response.status_code >= http_client.INTERNAL_SERVER_ERROR: cls = HttpServerError # 4XX status codes are client request errors - elif (http_client.BAD_REQUEST <= response.status_code < - http_client.INTERNAL_SERVER_ERROR): + elif (http_client.BAD_REQUEST <= response.status_code + < http_client.INTERNAL_SERVER_ERROR): cls = HTTPClientError else: cls = HttpError diff --git a/ironicclient/common/base.py b/ironicclient/common/base.py index 294f06ac7..cc3cb7030 100644 --- a/ironicclient/common/base.py +++ b/ironicclient/common/base.py @@ -19,9 +19,7 @@ import abc import copy -import six - -import six.moves.urllib.parse as urlparse +from urllib import parse as urlparse from ironicclient.common.apiclient import base from ironicclient import exc @@ -39,8 +37,7 @@ def getid(obj): return obj -@six.add_metaclass(abc.ABCMeta) -class Manager(object): +class Manager(object, metaclass=abc.ABCMeta): """Provides CRUD operations with a particular API.""" def __init__(self, api): @@ -55,23 +52,30 @@ def _path(self, resource_id=None): return ('/v1/%s/%s' % (self._resource_name, resource_id) if resource_id else '/v1/%s' % self._resource_name) - @abc.abstractproperty + @property + @abc.abstractmethod def resource_class(self): """The resource class """ - @abc.abstractproperty + @property + @abc.abstractmethod def _resource_name(self): """The resource name. """ - def _get(self, resource_id, fields=None): + def _get(self, resource_id, fields=None, os_ironic_api_version=None, + global_request_id=None): """Retrieve a resource. :param resource_id: Identifier of the resource. :param fields: List of specific fields to be returned. + :param os_ironic_api_version: String version (e.g. "1.35") to use for + the request. If not specified, the client's default is used. + :param global_request_id: String containing global request ID header + value (in form "req-") to use for the request. :raises exc.ValidationError: For invalid resource_id arg value. """ @@ -85,19 +89,29 @@ def _get(self, resource_id, fields=None): resource_id += ','.join(fields) try: - return self._list(self._path(resource_id))[0] + return self._list( + self._path(resource_id), + os_ironic_api_version=os_ironic_api_version, + global_request_id=global_request_id)[0] except IndexError: return None - def _get_as_dict(self, resource_id, fields=None): + def _get_as_dict(self, resource_id, fields=None, + os_ironic_api_version=None, global_request_id=None): """Retrieve a resource as a dictionary :param resource_id: Identifier of the resource. :param fields: List of specific fields to be returned. + :param os_ironic_api_version: String version (e.g. "1.35") to use for + the request. If not specified, the client's default is used. + :param global_request_id: String containing global request ID header + value (in form "req-") to use for the request. :returns: a dictionary representing the resource; may be empty """ - resource = self._get(resource_id, fields=fields) + resource = self._get(resource_id, fields=fields, + os_ironic_api_version=os_ironic_api_version, + global_request_id=global_request_id) if resource: return resource.to_dict() else: @@ -118,7 +132,8 @@ def _format_body_data(self, body, response_key): return data def _list_pagination(self, url, response_key=None, obj_class=None, - limit=None): + limit=None, os_ironic_api_version=None, + global_request_id=None): """Retrieve a list of items. The Ironic API is configured to return a maximum number of @@ -134,19 +149,42 @@ def _list_pagination(self, url, response_key=None, obj_class=None, :param obj_class: class for constructing the returned objects. :param limit: maximum number of items to return. If None returns everything. - + :param os_ironic_api_version: String version (e.g. "1.35") to use for + the request. If not specified, the client's default is used. + :param global_request_id: String containing global request ID header + value (in form "req-") to use for the request. """ if obj_class is None: obj_class = self.resource_class if limit is not None: limit = int(limit) + kwargs = {"headers": {}} + if os_ironic_api_version is not None: + kwargs['headers'][ + 'X-OpenStack-Ironic-API-Version'] = os_ironic_api_version + if global_request_id is not None: + kwargs["headers"]["X-Openstack-Request-Id"] = global_request_id + + # NOTE(jroll) + # endpoint_trimmed is what is prepended if we only pass a path + # to json_request. This might be something like + # https://example.com:4443/baremetal + # The code below which parses the 'next' URL keeps any path prefix + # we're using, so it ends up calling json_request() with e.g. + # /baremetal/v1/nodes. When this is appended to endpoint_trimmed, + # we end up with a full URL of e.g. + # https://example.com:4443/baremetal/baremetal/v1/nodes. + # Parse out the prefix from the base endpoint here, so we can strip + # it below and make sure we're passing a correct path. + endpoint_parts = urlparse.urlparse(self.api.endpoint_trimmed) + url_path_prefix = endpoint_parts[2] object_list = [] object_count = 0 limit_reached = False while url: - resp, body = self.api.json_request('GET', url) + resp, body = self.api.json_request('GET', url, **kwargs) data = self._format_body_data(body, response_key) for obj in data: object_list.append(obj_class(self, obj, loaded=True)) @@ -166,56 +204,114 @@ def _list_pagination(self, url, response_key=None, obj_class=None, # the scheme and netloc url_parts = list(urlparse.urlparse(url)) url_parts[0] = url_parts[1] = '' + if url_path_prefix: + url_parts[2] = url_parts[2].replace( + url_path_prefix, '', 1) url = urlparse.urlunparse(url_parts) return object_list - def _list(self, url, response_key=None, obj_class=None, body=None): - resp, body = self.api.json_request('GET', url) + def __list(self, url, response_key=None, body=None, + os_ironic_api_version=None, global_request_id=None): + kwargs = {"headers": {}} + + if os_ironic_api_version is not None: + kwargs['headers'][ + 'X-OpenStack-Ironic-API-Version'] = os_ironic_api_version + if global_request_id is not None: + kwargs["headers"]["X-Openstack-Request-Id"] = global_request_id + resp, body = self.api.json_request('GET', url, **kwargs) + + data = self._format_body_data(body, response_key) + return data + + def _list(self, url, response_key=None, obj_class=None, body=None, + os_ironic_api_version=None, global_request_id=None): if obj_class is None: obj_class = self.resource_class - data = self._format_body_data(body, response_key) + data = self.__list(url, response_key=response_key, body=body, + os_ironic_api_version=os_ironic_api_version, + global_request_id=global_request_id) return [obj_class(self, res, loaded=True) for res in data if res] - def _update(self, resource_id, patch, method='PATCH'): + def _list_primitives(self, url, response_key=None, + os_ironic_api_version=None, global_request_id=None): + return self.__list(url, response_key=response_key, + os_ironic_api_version=os_ironic_api_version, + global_request_id=global_request_id) + + def _update(self, resource_id, patch, method='PATCH', + os_ironic_api_version=None, global_request_id=None, + params=None): """Update a resource. :param resource_id: Resource identifier. - :param patch: New version of a given resource. + :param patch: New version of a given resource, a dictionary or None. :param method: Name of the method for the request. + :param os_ironic_api_version: String version (e.g. "1.35") to use for + the request. If not specified, the client's default is used. + :param global_request_id: String containing global request ID header + value (in form "req-") to use for the request. + :param params: query parameters to pass. """ url = self._path(resource_id) - resp, body = self.api.json_request(method, url, body=patch) + kwargs = {"headers": {}} + if patch is not None: + kwargs['body'] = patch + if os_ironic_api_version is not None: + kwargs['headers'][ + 'X-OpenStack-Ironic-API-Version'] = os_ironic_api_version + if global_request_id is not None: + kwargs["headers"]["X-Openstack-Request-Id"] = global_request_id + if params: + kwargs['params'] = params + resp, body = self.api.json_request(method, url, **kwargs) # PATCH/PUT requests may not return a body if body: return self.resource_class(self, body) - def _delete(self, resource_id): + def _delete(self, resource_id, + os_ironic_api_version=None, global_request_id=None): """Delete a resource. :param resource_id: Resource identifier. + :param os_ironic_api_version: String version (e.g. "1.35") to use for + the request. If not specified, the client's default is used. + :param global_request_id: String containing global request ID header + value (in form "req-") to use for the request. """ - self.api.raw_request('DELETE', self._path(resource_id)) + headers = {} + if os_ironic_api_version is not None: + headers["X-OpenStack-Ironic-API-Version"] = os_ironic_api_version + if global_request_id is not None: + headers["X-Openstack-Request-Id"] = global_request_id + self.api.raw_request('DELETE', self._path(resource_id), + headers=headers) -@six.add_metaclass(abc.ABCMeta) -class CreateManager(Manager): +class CreateManager(Manager, metaclass=abc.ABCMeta): """Provides creation operations with a particular API.""" - @abc.abstractproperty + @property + @abc.abstractmethod def _creation_attributes(self): """A list of required creation attributes for a resource type. """ - def create(self, **kwargs): + def create(self, os_ironic_api_version=None, global_request_id=None, + **kwargs): """Create a resource based on a kwargs dictionary of attributes. :param kwargs: A dictionary containing the attributes of the resource that will be created. + :param os_ironic_api_version: String version (e.g. "1.35") to use for + the request. If not specified, the client's default is used. + :param global_request_id: String containing global request ID header + value (in form "req-") to use for the request. :raises exc.InvalidAttribute: For invalid attributes that are not needed to create the resource. """ @@ -233,8 +329,14 @@ def create(self, **kwargs): 'needed to create %(resource)s.' % {'resource': self._resource_name, 'attrs': '","'.join(invalid)}) + headers = {} + if os_ironic_api_version is not None: + headers['X-OpenStack-Ironic-API-Version'] = os_ironic_api_version + if global_request_id is not None: + headers["X-Openstack-Request-Id"] = global_request_id url = self._path() - resp, body = self.api.json_request('POST', url, body=new) + resp, body = self.api.json_request('POST', url, body=new, + headers=headers) if body: return self.resource_class(self, body) diff --git a/ironicclient/common/cliutils.py b/ironicclient/common/cliutils.py deleted file mode 100644 index ce396a08f..000000000 --- a/ironicclient/common/cliutils.py +++ /dev/null @@ -1,293 +0,0 @@ -# Copyright 2012 Red Hat, Inc. -# -# 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. - -# W0603: Using the global statement -# W0621: Redefining name %s from outer scope -# pylint: disable=W0603,W0621 - -from __future__ import print_function - -import getpass -import inspect -import json -import os -import sys -import textwrap - -from oslo_utils import encodeutils -from oslo_utils import strutils -import prettytable -import six -from six import moves - -from ironicclient.common.i18n import _ - - -class MissingArgs(Exception): - """Supplied arguments are not sufficient for calling a function.""" - def __init__(self, missing): - self.missing = missing - msg = _("Missing arguments: %s") % ", ".join(missing) - super(MissingArgs, self).__init__(msg) - - -def validate_args(fn, *args, **kwargs): - """Check that the supplied args are sufficient for calling a function. - - >>> validate_args(lambda a: None) - Traceback (most recent call last): - ... - MissingArgs: Missing argument(s): a - >>> validate_args(lambda a, b, c, d: None, 0, c=1) - Traceback (most recent call last): - ... - MissingArgs: Missing argument(s): b, d - - :param fn: the function to check - :param args: the positional arguments supplied - :param kwargs: the keyword arguments supplied - """ - argspec = inspect.getargspec(fn) - - num_defaults = len(argspec.defaults or []) - required_args = argspec.args[:len(argspec.args) - num_defaults] - - def isbound(method): - return getattr(method, '__self__', None) is not None - - if isbound(fn): - required_args.pop(0) - - missing = [arg for arg in required_args if arg not in kwargs] - missing = missing[len(args):] - if missing: - raise MissingArgs(missing) - - -def arg(*args, **kwargs): - """Decorator for CLI args. - - Example: - - >>> @arg("name", help="Name of the new entity") - ... def entity_create(args): - ... pass - """ - def _decorator(func): - add_arg(func, *args, **kwargs) - return func - return _decorator - - -def env(*args, **kwargs): - """Returns the first environment variable set. - - If all are empty, defaults to '' or keyword arg `default`. - """ - for arg in args: - value = os.environ.get(arg) - if value: - return value - return kwargs.get('default', '') - - -def add_arg(func, *args, **kwargs): - """Bind CLI arguments to a shell.py `do_foo` function.""" - - if not hasattr(func, 'arguments'): - func.arguments = [] - - # NOTE(sirp): avoid dups that can occur when the module is shared across - # tests. - if (args, kwargs) not in func.arguments: - # Because of the semantics of decorator composition if we just append - # to the options list positional options will appear to be backwards. - func.arguments.insert(0, (args, kwargs)) - - -def unauthenticated(func): - """Adds 'unauthenticated' attribute to decorated function. - - Usage: - - >>> @unauthenticated - ... def mymethod(f): - ... pass - """ - func.unauthenticated = True - return func - - -def isunauthenticated(func): - """Checks if the function does not require authentication. - - Mark such functions with the `@unauthenticated` decorator. - - :returns: bool - """ - return getattr(func, 'unauthenticated', False) - - -def print_list(objs, fields, formatters=None, sortby_index=0, - mixed_case_fields=None, field_labels=None, json_flag=False): - """Print a list of objects or dict as a table, one row per object or dict. - - :param objs: iterable of :class:`Resource` - :param fields: attributes that correspond to columns, in order - :param formatters: `dict` of callables for field formatting - :param sortby_index: index of the field for sorting table rows - :param mixed_case_fields: fields corresponding to object attributes that - have mixed case names (e.g., 'serverId') - :param field_labels: Labels to use in the heading of the table, default to - fields. - :param json_flag: print the list as JSON instead of table - """ - def _get_name_and_data(field): - if field in formatters: - # The value of the field has to be modified. - # For example, it can be used to add extra fields. - return (field, formatters[field](o)) - - field_name = field.replace(' ', '_') - if field not in mixed_case_fields: - field_name = field.lower() - if isinstance(o, dict): - data = o.get(field_name, '') - else: - data = getattr(o, field_name, '') - return (field_name, data) - - formatters = formatters or {} - mixed_case_fields = mixed_case_fields or [] - field_labels = field_labels or fields - if len(field_labels) != len(fields): - raise ValueError(_("Field labels list %(labels)s has different number " - "of elements than fields list %(fields)s"), - {'labels': field_labels, 'fields': fields}) - - if sortby_index is None: - kwargs = {} - else: - kwargs = {'sortby': field_labels[sortby_index]} - pt = prettytable.PrettyTable(field_labels) - pt.align = 'l' - - json_array = [] - - for o in objs: - row = [] - for field in fields: - row.append(_get_name_and_data(field)) - if json_flag: - json_array.append(dict(row)) - else: - pt.add_row([r[1] for r in row]) - - if json_flag: - print(json.dumps(json_array, indent=4, separators=(',', ': '))) - elif six.PY3: - print(encodeutils.safe_encode(pt.get_string(**kwargs)).decode()) - else: - print(encodeutils.safe_encode(pt.get_string(**kwargs))) - - -def print_dict(dct, dict_property="Property", wrap=0, dict_value='Value', - json_flag=False): - """Print a `dict` as a table of two columns. - - :param dct: `dict` to print - :param dict_property: name of the first column - :param wrap: wrapping for the second column - :param dict_value: header label for the value (second) column - :param json_flag: print `dict` as JSON instead of table - """ - if json_flag: - print(json.dumps(dct, indent=4, separators=(',', ': '))) - return - pt = prettytable.PrettyTable([dict_property, dict_value]) - pt.align = 'l' - for k, v in sorted(dct.items()): - # convert dict to str to check length - if isinstance(v, dict): - v = six.text_type(v) - if wrap > 0: - v = textwrap.fill(six.text_type(v), wrap) - # if value has a newline, add in multiple rows - # e.g. fault with stacktrace - if v and isinstance(v, six.string_types) and r'\n' in v: - lines = v.strip().split(r'\n') - col1 = k - for line in lines: - pt.add_row([col1, line]) - col1 = '' - else: - pt.add_row([k, v]) - - if six.PY3: - print(encodeutils.safe_encode(pt.get_string()).decode()) - else: - print(encodeutils.safe_encode(pt.get_string())) - - -def get_password(max_password_prompts=3): - """Read password from TTY.""" - verify = strutils.bool_from_string(env("OS_VERIFY_PASSWORD")) - pw = None - if hasattr(sys.stdin, "isatty") and sys.stdin.isatty(): - # Check for Ctrl-D - try: - for __ in moves.range(max_password_prompts): - pw1 = getpass.getpass("OS Password: ") - if verify: - pw2 = getpass.getpass("Please verify: ") - else: - pw2 = pw1 - if pw1 == pw2 and pw1: - pw = pw1 - break - except EOFError: - pass - return pw - - -def service_type(stype): - """Adds 'service_type' attribute to decorated function. - - Usage: - - .. code-block:: python - - @service_type('volume') - def mymethod(f): - ... - """ - def inner(f): - f.service_type = stype - return f - return inner - - -def get_service_type(f): - """Retrieves service type from function.""" - return getattr(f, 'service_type', None) - - -def pretty_choice_list(l): - return ', '.join("'%s'" % i for i in l) - - -def exit(msg=''): - if msg: - print(msg, file=sys.stderr) - sys.exit(1) diff --git a/ironicclient/common/filecache.py b/ironicclient/common/filecache.py index d4fdf348d..0efd01921 100644 --- a/ironicclient/common/filecache.py +++ b/ironicclient/common/filecache.py @@ -14,26 +14,29 @@ # License for the specific language governing permissions and limitations # under the License. +from __future__ import annotations + import logging import os +from typing import cast -import appdirs import dogpile.cache +import platformdirs LOG = logging.getLogger(__name__) -AUTHOR = 'openstack' -PROGNAME = 'python-ironicclient' +AUTHOR: str = 'openstack' +PROGNAME: str = 'python-ironicclient' -CACHE = None -CACHE_DIR = appdirs.user_cache_dir(PROGNAME, AUTHOR) -CACHE_EXPIRY_ENV_VAR = 'IRONICCLIENT_CACHE_EXPIRY' # environment variable -CACHE_FILENAME = os.path.join(CACHE_DIR, 'ironic-api-version.dbm') -DEFAULT_EXPIRY = 300 # seconds +CACHE: dogpile.cache.CacheRegion | None = None +CACHE_DIR: str = platformdirs.user_cache_dir(PROGNAME, AUTHOR) +CACHE_EXPIRY_ENV_VAR: str = 'IRONICCLIENT_CACHE_EXPIRY' # environment variable +CACHE_FILENAME: str = os.path.join(CACHE_DIR, 'ironic-api-version.dbm') +DEFAULT_EXPIRY: int = 300 # seconds -def _get_cache(): +def _get_cache() -> dogpile.cache.CacheRegion: """Configure file caching.""" global CACHE if CACHE is None: @@ -65,12 +68,12 @@ def _get_cache(): return CACHE -def _build_key(host, port): +def _build_key(host: str, port: str | int) -> str: """Build a key based upon the hostname or address supplied.""" return "%s:%s" % (host, port) -def save_data(host, port, data): +def save_data(host: str, port: str | int, data: str) -> None: """Save 'data' for a particular 'host' in the appropriate cache dir. param host: The host that we need to save data for @@ -81,7 +84,9 @@ def save_data(host, port, data): _get_cache().set(key, data) -def retrieve_data(host, port, expiry=None): +def retrieve_data( + host: str, port: str | int, expiry: int | None = None +) -> str | None: """Retrieve the version stored for an ironic 'host', if it's not stale. Check to see if there is valid cached data for the host/port @@ -100,4 +105,4 @@ def retrieve_data(host, port, expiry=None): if data == dogpile.cache.api.NO_VALUE: return None - return data + return cast(str, data) diff --git a/ironicclient/common/http.py b/ironicclient/common/http.py index 0c6de897f..feba1405e 100644 --- a/ironicclient/common/http.py +++ b/ironicclient/common/http.py @@ -13,25 +13,18 @@ # License for the specific language governing permissions and limitations # under the License. -import copy -from distutils.version import StrictVersion import functools -import hashlib +from http import client as http_client +import json import logging -import os -import socket -import ssl +import re import textwrap +import threading import time +from urllib import parse as urlparse from keystoneauth1 import adapter from keystoneauth1 import exceptions as kexc -from oslo_serialization import jsonutils -from oslo_utils import strutils -import requests -import six -from six.moves import http_client -import six.moves.urllib.parse as urlparse from ironicclient.common import filecache from ironicclient.common.i18n import _ @@ -44,48 +37,79 @@ # http://specs.openstack.org/openstack/ironic-specs/specs/kilo/api-microversions.html # noqa # for full details. DEFAULT_VER = '1.9' - +LAST_KNOWN_API_VERSION = 109 +LATEST_VERSION = '1.{}'.format(LAST_KNOWN_API_VERSION) LOG = logging.getLogger(__name__) USER_AGENT = 'python-ironicclient' CHUNKSIZE = 1024 * 64 # 64kB -API_VERSION = '/v1' +_MAJOR_VERSION = 1 +API_VERSION = '/v%d' % _MAJOR_VERSION API_VERSION_SELECTED_STATES = ('user', 'negotiated', 'cached', 'default') - DEFAULT_MAX_RETRIES = 5 DEFAULT_RETRY_INTERVAL = 2 SENSITIVE_HEADERS = ('X-Auth-Token',) - SUPPORTED_ENDPOINT_SCHEME = ('http', 'https') +_API_VERSION_RE = re.compile(r'/+(v%d)?/*$' % _MAJOR_VERSION) + + +@functools.total_ordering +class _Version: + _version_re = re.compile(r'^(\d) \. (\d+)$', re.VERBOSE | re.ASCII) + + def __init__(self, version): + match = self._version_re.match(version) + if not match: + raise ValueError('invalid version number %s' % version) + major, minor = match.group(1, 2) + self.version = (int(major), int(minor)) + + def __str__(self): + return '.'.join(str(v) for v in self.version) + + def __eq__(self, other): + return self.version == other.version + + def __lt__(self, other): + return self.version < other.version + def _trim_endpoint_api_version(url): """Trim API version and trailing slash from endpoint.""" - return url.rstrip('/').rstrip(API_VERSION) + return re.sub(_API_VERSION_RE, '', url) def _extract_error_json(body): """Return error_message from the HTTP response body.""" - error_json = {} try: - body_json = jsonutils.loads(body) - if 'error_message' in body_json: - raw_msg = body_json['error_message'] - error_json = jsonutils.loads(raw_msg) + body_json = json.loads(body) except ValueError: - pass + return {} + + if 'error_message' not in body_json: + return {} + + try: + error_json = json.loads(body_json['error_message']) + except ValueError: + return body_json + + err_msg = (error_json.get('faultstring') or error_json.get('description')) + if err_msg: + body_json['error_message'] = err_msg - return error_json + return body_json -def get_server(endpoint): - """Extract and return the server & port that we're connecting to.""" - if endpoint is None: +def get_server(url): + """Extract and return the server & port.""" + if url is None: return None, None - parts = urlparse.urlparse(endpoint) + parts = urlparse.urlparse(url) return parts.hostname, str(parts.port) @@ -95,9 +119,50 @@ def negotiate_version(self, conn, resp): Assumption: Called after receiving a 406 error when doing a request. - param conn: A connection object - param resp: The response object from http request + :param conn: A connection object + :param resp: The response object from http request """ + def _query_server(conn): + if (self.os_ironic_api_version + and not isinstance(self.os_ironic_api_version, list) + and self.os_ironic_api_version != 'latest'): + base_version = ("/v%s" % + str(self.os_ironic_api_version).split('.')[0]) + else: + base_version = API_VERSION + # Raise exception on client or server error. + resp = self._make_simple_request(conn, 'GET', base_version) + if not resp.ok: + raise exc.from_response(resp, method='GET', url=base_version) + return resp + + version_overridden = False + + if (resp and hasattr(resp, 'request') + and hasattr(resp.request, 'headers')): + orig_hdr = resp.request.headers + # Get the version of the client's last request and fallback + # to the default for things like unit tests to not cause + # migraines. + req_api_ver = orig_hdr.get('X-OpenStack-Ironic-API-Version', + self.os_ironic_api_version) + else: + req_api_ver = self.os_ironic_api_version + if (resp and req_api_ver != self.os_ironic_api_version + and self.api_version_select_state == 'negotiated'): + # If we have a non-standard api version on the request, + # but we think we've negotiated, then the call was overridden. + # We should report the error with the called version + requested_version = req_api_ver + # And then we shouldn't save the newly negotiated + # version of this negotiation because we have been + # overridden a request. + version_overridden = True + else: + requested_version = self.os_ironic_api_version + + if not resp: + resp = _query_server(conn) if self.api_version_select_state not in API_VERSION_SELECTED_STATES: raise RuntimeError( _('Error: self.api_version_select_state should be one of the ' @@ -110,37 +175,86 @@ def negotiate_version(self, conn, resp): # the supported version range if not max_ver: LOG.debug('No version header in response, requesting from server') - if self.os_ironic_api_version: - base_version = ("/v%s" % - str(self.os_ironic_api_version).split('.')[0]) - else: - base_version = API_VERSION - resp = self._make_simple_request(conn, 'GET', base_version) + resp = _query_server(conn) min_ver, max_ver = self._parse_version_headers(resp) + # Reset the maximum version that we permit + + if _Version(max_ver) > _Version(LATEST_VERSION): + LOG.debug("Remote API version %(max_ver)s is greater than the " + "version supported by ironicclient. Maximum available " + "version is %(client_ver)s", + {'max_ver': max_ver, + 'client_ver': LATEST_VERSION}) + max_ver = LATEST_VERSION + # If the user requested an explicit version or we have negotiated a # version and still failing then error now. The server could # support the version requested but the requested operation may not # be supported by the requested version. - if self.api_version_select_state == 'user': + # TODO(TheJulia): We should break this method into several parts, + # such as a sanity check/error method. + if ((self.api_version_select_state == 'user' + and not self._must_negotiate_version()) + or (self.api_version_select_state == 'negotiated' + and version_overridden)): raise exc.UnsupportedVersion(textwrap.fill( _("Requested API version %(req)s is not supported by the " - "server or the requested operation is not supported by the " - "requested version. Supported version range is %(min)s to " + "server, client, or the requested operation is not " + "supported by the requested version. " + "Supported version range is %(min)s to " "%(max)s") - % {'req': self.os_ironic_api_version, + % {'req': requested_version, 'min': min_ver, 'max': max_ver})) - if self.api_version_select_state == 'negotiated': + if (self.api_version_select_state == 'negotiated'): raise exc.UnsupportedVersion(textwrap.fill( - _("No API version was specified and the requested operation " + _("No API version was specified or the requested operation " "was not supported by the client's negotiated API version " "%(req)s. Supported version range is: %(min)s to %(max)s") - % {'req': self.os_ironic_api_version, + % {'req': requested_version, 'min': min_ver, 'max': max_ver})) - negotiated_ver = str(min(StrictVersion(self.os_ironic_api_version), - StrictVersion(max_ver))) - if negotiated_ver < min_ver: + if isinstance(requested_version, str): + if requested_version == 'latest': + negotiated_ver = max_ver + else: + negotiated_ver = str( + min(_Version(requested_version), _Version(max_ver)) + ) + + elif isinstance(requested_version, list): + if 'latest' in requested_version: + raise ValueError(textwrap.fill( + _("The 'latest' API version can not be requested " + "in a list of versions. Please explicitly request " + "'latest' or request only versions between " + "%(min)s to %(max)s") + % {'min': min_ver, 'max': max_ver})) + + versions = [] + for version in requested_version: + if _Version(min_ver) <= _Version(version) <= _Version(max_ver): + versions.append(_Version(version)) + if versions: + negotiated_ver = str(max(versions)) + else: + raise exc.UnsupportedVersion(textwrap.fill( + _("Requested API version specified and the requested " + "operation was not supported by the client's " + "requested API version %(req)s. Supported " + "version range is: %(min)s to %(max)s") + % {'req': requested_version, + 'min': min_ver, 'max': max_ver})) + + else: + raise ValueError(textwrap.fill( + _("Requested API version %(req)s type is unsupported. " + "Valid types are Strings such as '1.1', 'latest' " + "or a list of string values representing API versions.") + % {'req': requested_version})) + + if _Version(negotiated_ver) < _Version(min_ver): negotiated_ver = min_ver + # server handles microversions, but doesn't support # the requested version, so try a negotiated version self.api_version_select_state = 'negotiated' @@ -148,16 +262,15 @@ def negotiate_version(self, conn, resp): LOG.debug('Negotiated API version is %s', negotiated_ver) # Cache the negotiated version for this server - host, port = get_server(self.endpoint) + endpoint_override = getattr(self, 'endpoint_override', None) + host, port = get_server(endpoint_override) filecache.save_data(host=host, port=port, data=negotiated_ver) return negotiated_ver def _generic_parse_version_headers(self, accessor_func): - min_ver = accessor_func('X-OpenStack-Ironic-API-Minimum-Version', - None) - max_ver = accessor_func('X-OpenStack-Ironic-API-Maximum-Version', - None) + min_ver = accessor_func('X-OpenStack-Ironic-API-Minimum-Version', None) + max_ver = accessor_func('X-OpenStack-Ironic-API-Maximum-Version', None) return min_ver, max_ver def _parse_version_headers(self, accessor_func): @@ -168,6 +281,11 @@ def _make_simple_request(self, conn, method, url): # NOTE(jlvillal): Declared for unit testing purposes raise NotImplementedError() + def _must_negotiate_version(self): + return (self.api_version_select_state == 'user' + and (self.os_ironic_api_version == 'latest' + or isinstance(self.os_ironic_api_version, list))) + _RETRY_EXCEPTIONS = (exc.Conflict, exc.ServiceUnavailable, exc.ConnectionRefused, kexc.RetriableConnectionFailure) @@ -202,283 +320,6 @@ def wrapper(self, url, method, **kwargs): return wrapper -class HTTPClient(VersionNegotiationMixin): - - def __init__(self, endpoint, **kwargs): - self.endpoint = endpoint - self.endpoint_trimmed = _trim_endpoint_api_version(endpoint) - self.auth_token = kwargs.get('token') - self.auth_ref = kwargs.get('auth_ref') - self.os_ironic_api_version = kwargs.get('os_ironic_api_version', - DEFAULT_VER) - self.api_version_select_state = kwargs.get( - 'api_version_select_state', 'default') - self.conflict_max_retries = kwargs.pop('max_retries', - DEFAULT_MAX_RETRIES) - self.conflict_retry_interval = kwargs.pop('retry_interval', - DEFAULT_RETRY_INTERVAL) - self.session = requests.Session() - - parts = urlparse.urlparse(endpoint) - if parts.scheme not in SUPPORTED_ENDPOINT_SCHEME: - msg = _('Unsupported scheme: %s') % parts.scheme - raise exc.EndpointException(msg) - - if parts.scheme == 'https': - if kwargs.get('insecure') is True: - self.session.verify = False - elif kwargs.get('ca_file'): - self.session.verify = kwargs['ca_file'] - self.session.cert = (kwargs.get('cert_file'), - kwargs.get('key_file')) - - def _process_header(self, name, value): - """Redacts any sensitive header - - Redact a header that contains sensitive information, by returning an - updated header with the sha1 hash of that value. The redacted value is - prefixed by '{SHA1}' because that's the convention used within - OpenStack. - - :returns: A tuple of (name, value) - name: the safe encoding format of name - value: the redacted value if name is x-auth-token, - or the safe encoding format of name - - """ - if name in SENSITIVE_HEADERS: - v = value.encode('utf-8') - h = hashlib.sha1(v) - d = h.hexdigest() - return (name, "{SHA1}%s" % d) - else: - return (name, value) - - def log_curl_request(self, method, url, kwargs): - curl = ['curl -i -X %s' % method] - - for (key, value) in kwargs['headers'].items(): - header = '-H \'%s: %s\'' % self._process_header(key, value) - curl.append(header) - - if not self.session.verify: - curl.append('-k') - elif isinstance(self.session.verify, six.string_types): - curl.append('--cacert %s' % self.session.verify) - - if self.session.cert: - curl.append('--cert %s' % self.session.cert[0]) - curl.append('--key %s' % self.session.cert[1]) - - if 'body' in kwargs: - body = strutils.mask_password(kwargs['body']) - curl.append('-d \'%s\'' % body) - - curl.append(urlparse.urljoin(self.endpoint_trimmed, url)) - LOG.debug(' '.join(curl)) - - @staticmethod - def log_http_response(resp, body=None): - # NOTE(aarefiev): resp.raw is urllib3 response object, it's used - # only to get 'version', response from request with 'stream = True' - # should be used for raw reading. - status = (resp.raw.version / 10.0, resp.status_code, resp.reason) - dump = ['\nHTTP/%.1f %s %s' % status] - dump.extend(['%s: %s' % (k, v) for k, v in resp.headers.items()]) - dump.append('') - if body: - body = strutils.mask_password(body) - dump.extend([body, '']) - LOG.debug('\n'.join(dump)) - - def _make_connection_url(self, url): - return urlparse.urljoin(self.endpoint_trimmed, url) - - def _parse_version_headers(self, resp): - return self._generic_parse_version_headers(resp.headers.get) - - def _make_simple_request(self, conn, method, url): - return conn.request(method, self._make_connection_url(url)) - - @with_retries - def _http_request(self, url, method, **kwargs): - """Send an http request with the specified characteristics. - - Wrapper around request.Session.request to handle tasks such - as setting headers and error handling. - """ - # Copy the kwargs so we can reuse the original in case of redirects - kwargs['headers'] = copy.deepcopy(kwargs.get('headers', {})) - kwargs['headers'].setdefault('User-Agent', USER_AGENT) - if self.os_ironic_api_version: - kwargs['headers'].setdefault('X-OpenStack-Ironic-API-Version', - self.os_ironic_api_version) - if self.auth_token: - kwargs['headers'].setdefault('X-Auth-Token', self.auth_token) - - self.log_curl_request(method, url, kwargs) - - # NOTE(aarefiev): This is for backwards compatibility, request - # expected body in 'data' field, previously we used httplib, - # which expected 'body' field. - body = kwargs.pop('body', None) - if body: - kwargs['data'] = body - - conn_url = self._make_connection_url(url) - try: - resp = self.session.request(method, - conn_url, - **kwargs) - - # TODO(deva): implement graceful client downgrade when connecting - # to servers that did not support microversions. Details here: - # http://specs.openstack.org/openstack/ironic-specs/specs/kilo/api-microversions.html#use-case-3b-new-client-communicating-with-a-old-ironic-user-specified # noqa - - if resp.status_code == http_client.NOT_ACCEPTABLE: - negotiated_ver = self.negotiate_version(self.session, resp) - kwargs['headers']['X-OpenStack-Ironic-API-Version'] = ( - negotiated_ver) - return self._http_request(url, method, **kwargs) - - except requests.exceptions.RequestException as e: - message = (_("Error has occurred while handling " - "request for %(url)s: %(e)s") % - dict(url=conn_url, e=e)) - # NOTE(aarefiev): not valid request(invalid url, missing schema, - # and so on), retrying is not needed. - if isinstance(e, ValueError): - raise exc.ValidationError(message) - - raise exc.ConnectionRefused(message) - - body_str = None - if resp.headers.get('Content-Type') == 'application/octet-stream': - body_iter = resp.iter_content(chunk_size=CHUNKSIZE) - self.log_http_response(resp) - else: - # Read body into string if it isn't obviously image data - body_str = resp.text - self.log_http_response(resp, body_str) - body_iter = six.StringIO(body_str) - - if resp.status_code >= http_client.BAD_REQUEST: - error_json = _extract_error_json(body_str) - # NOTE(vdrok): exceptions from ironic controllers' _lookup methods - # are constructed directly by pecan instead of wsme, and contain - # only description field - raise exc.from_response( - resp, (error_json.get('faultstring') or - error_json.get('description')), - error_json.get('debuginfo'), method, url) - elif resp.status_code in (http_client.MOVED_PERMANENTLY, - http_client.FOUND, - http_client.USE_PROXY): - # Redirected. Reissue the request to the new location. - return self._http_request(resp['location'], method, **kwargs) - elif resp.status_code == http_client.MULTIPLE_CHOICES: - raise exc.from_response(resp, method=method, url=url) - - return resp, body_iter - - def json_request(self, method, url, **kwargs): - kwargs.setdefault('headers', {}) - kwargs['headers'].setdefault('Content-Type', 'application/json') - kwargs['headers'].setdefault('Accept', 'application/json') - - if 'body' in kwargs: - kwargs['body'] = jsonutils.dump_as_bytes(kwargs['body']) - - resp, body_iter = self._http_request(url, method, **kwargs) - content_type = resp.headers.get('Content-Type') - - if (resp.status_code in (http_client.NO_CONTENT, - http_client.RESET_CONTENT) - or content_type is None): - return resp, list() - - if 'application/json' in content_type: - body = ''.join([chunk for chunk in body_iter]) - try: - body = jsonutils.loads(body) - except ValueError: - LOG.error('Could not decode response body as JSON') - else: - body = None - - return resp, body - - def raw_request(self, method, url, **kwargs): - kwargs.setdefault('headers', {}) - kwargs['headers'].setdefault('Content-Type', - 'application/octet-stream') - return self._http_request(url, method, **kwargs) - - -class VerifiedHTTPSConnection(six.moves.http_client.HTTPSConnection): - """httplib-compatible connection using client-side SSL authentication - - :see http://code.activestate.com/recipes/ - 577548-https-httplib-client-connection-with-certificate-v/ - """ - - def __init__(self, host, port, key_file=None, cert_file=None, - ca_file=None, timeout=None, insecure=False): - six.moves.http_client.HTTPSConnection.__init__(self, host, port, - key_file=key_file, - cert_file=cert_file) - self.key_file = key_file - self.cert_file = cert_file - if ca_file is not None: - self.ca_file = ca_file - else: - self.ca_file = self.get_system_ca_file() - self.timeout = timeout - self.insecure = insecure - - def connect(self): - """Connect to a host on a given (SSL) port. - - If ca_file is pointing somewhere, use it to check Server Certificate. - - Redefined/copied and extended from httplib.py:1105 (Python 2.6.x). - This is needed to pass cert_reqs=ssl.CERT_REQUIRED as parameter to - ssl.wrap_socket(), which forces SSL to check server certificate against - our client certificate. - """ - sock = socket.create_connection((self.host, self.port), self.timeout) - - if self._tunnel_host: - self.sock = sock - self._tunnel() - - if self.insecure is True: - kwargs = {'cert_reqs': ssl.CERT_NONE} - else: - kwargs = {'cert_reqs': ssl.CERT_REQUIRED, 'ca_certs': self.ca_file} - - if self.cert_file: - kwargs['certfile'] = self.cert_file - if self.key_file: - kwargs['keyfile'] = self.key_file - - self.sock = ssl.wrap_socket(sock, **kwargs) - - @staticmethod - def get_system_ca_file(): - """Return path to system default CA file.""" - # Standard CA file locations for Debian/Ubuntu, RedHat/Fedora, - # Suse, FreeBSD/OpenBSD - ca_path = ['/etc/ssl/certs/ca-certificates.crt', - '/etc/pki/tls/certs/ca-bundle.crt', - '/etc/ssl/ca-bundle.pem', - '/etc/ssl/cert.pem'] - for ca in ca_path: - if os.path.exists(ca): - return ca - return None - - class SessionClient(VersionNegotiationMixin, adapter.LegacyJsonAdapter): """HTTP client based on Keystone client session.""" @@ -487,37 +328,69 @@ def __init__(self, api_version_select_state, max_retries, retry_interval, - endpoint, **kwargs): self.os_ironic_api_version = os_ironic_api_version self.api_version_select_state = api_version_select_state self.conflict_max_retries = max_retries self.conflict_retry_interval = retry_interval - self.endpoint = endpoint + if isinstance(kwargs.get('endpoint_override'), str): + kwargs['endpoint_override'] = _trim_endpoint_api_version( + kwargs['endpoint_override']) super(SessionClient, self).__init__(**kwargs) + endpoint_filter = self._get_endpoint_filter() + endpoint = self.get_endpoint(**endpoint_filter) + if endpoint is None: + raise exc.EndpointNotFound( + _('The Bare Metal API endpoint cannot be detected and was ' + 'not provided explicitly')) + self.endpoint_trimmed = _trim_endpoint_api_version(endpoint) + self._first_negotiation_lock = threading.Lock() + def _parse_version_headers(self, resp): return self._generic_parse_version_headers(resp.headers.get) + def _get_endpoint_filter(self): + return { + 'interface': self.interface, + 'service_type': self.service_type, + 'region_name': self.region_name + } + def _make_simple_request(self, conn, method, url): # NOTE: conn is self.session for this class - return conn.request(url, method, raise_exc=False) + return conn.request(url, method, raise_exc=False, + user_agent=USER_AGENT, + endpoint_filter=self._get_endpoint_filter(), + endpoint_override=self.endpoint_override) @with_retries def _http_request(self, url, method, **kwargs): + + # NOTE(TheJulia): self.os_ironic_api_version is reset in + # the self.negotiate_version() call if negotiation occurs. + if self.os_ironic_api_version and self._must_negotiate_version(): + with self._first_negotiation_lock: + if self._must_negotiate_version(): + self.negotiate_version(self.session, None) + kwargs.setdefault('user_agent', USER_AGENT) kwargs.setdefault('auth', self.auth) - if isinstance(self.endpoint_override, six.string_types): - kwargs.setdefault( - 'endpoint_override', - _trim_endpoint_api_version(self.endpoint_override) - ) + if isinstance(self.endpoint_override, str): + kwargs.setdefault('endpoint_override', self.endpoint_override) if getattr(self, 'os_ironic_api_version', None): kwargs['headers'].setdefault('X-OpenStack-Ironic-API-Version', self.os_ironic_api_version) + for k, v in self.additional_headers.items(): + kwargs['headers'].setdefault(k, v) + + if self.global_request_id is not None: + kwargs['headers'].setdefault( + "X-OpenStack-Request-ID", self.global_request_id) + endpoint_filter = kwargs.setdefault('endpoint_filter', {}) endpoint_filter.setdefault('interface', self.interface) endpoint_filter.setdefault('service_type', self.service_type) @@ -532,11 +405,7 @@ def _http_request(self, url, method, **kwargs): return self._http_request(url, method, **kwargs) if resp.status_code >= http_client.BAD_REQUEST: error_json = _extract_error_json(resp.content) - # NOTE(vdrok): exceptions from ironic controllers' _lookup methods - # are constructed directly by pecan instead of wsme, and contain - # only description field - raise exc.from_response(resp, (error_json.get('faultstring') or - error_json.get('description')), + raise exc.from_response(resp, error_json.get('error_message'), error_json.get('debuginfo'), method, url) elif resp.status_code in (http_client.MOVED_PERMANENTLY, http_client.FOUND, http_client.USE_PROXY): @@ -553,14 +422,14 @@ def json_request(self, method, url, **kwargs): kwargs['headers'].setdefault('Accept', 'application/json') if 'body' in kwargs: - kwargs['data'] = jsonutils.dump_as_bytes(kwargs.pop('body')) + kwargs['json'] = kwargs.pop('body') resp = self._http_request(url, method, **kwargs) body = resp.content content_type = resp.headers.get('content-type', None) status = resp.status_code - if (status in (http_client.NO_CONTENT, http_client.RESET_CONTENT) or - content_type is None): + if (status in (http_client.NO_CONTENT, http_client.RESET_CONTENT) + or content_type is None): return resp, list() if 'application/json' in content_type: try: @@ -579,8 +448,7 @@ def raw_request(self, method, url, **kwargs): return self._http_request(url, method, **kwargs) -def _construct_http_client(endpoint=None, - session=None, +def _construct_http_client(session, token=None, auth_ref=None, os_ironic_api_version=DEFAULT_VER, @@ -593,48 +461,30 @@ def _construct_http_client(endpoint=None, key_file=None, insecure=None, **kwargs): - if session: - kwargs.setdefault('service_type', 'baremetal') - kwargs.setdefault('user_agent', 'python-ironicclient') - kwargs.setdefault('interface', kwargs.pop('endpoint_type', None)) - kwargs.setdefault('endpoint_override', endpoint) - - ignored = {'token': token, - 'auth_ref': auth_ref, - 'timeout': timeout != 600, - 'ca_file': ca_file, - 'cert_file': cert_file, - 'key_file': key_file, - 'insecure': insecure} - - dvars = [k for k, v in ignored.items() if v] - - if dvars: - LOG.warning('The following arguments are ignored when using ' - 'the session to construct a client: %s', - ', '.join(dvars)) - - return SessionClient(session=session, - os_ironic_api_version=os_ironic_api_version, - api_version_select_state=api_version_select_state, - max_retries=max_retries, - retry_interval=retry_interval, - endpoint=endpoint, - **kwargs) - else: - if kwargs: - LOG.warning('The following arguments are being ignored when ' - 'constructing the client: %s'), ', '.join(kwargs) - - return HTTPClient(endpoint=endpoint, - token=token, - auth_ref=auth_ref, - os_ironic_api_version=os_ironic_api_version, - api_version_select_state=api_version_select_state, - max_retries=max_retries, - retry_interval=retry_interval, - timeout=timeout, - ca_file=ca_file, - cert_file=cert_file, - key_file=key_file, - insecure=insecure) + + kwargs.setdefault('service_type', 'baremetal') + kwargs.setdefault('user_agent', 'python-ironicclient') + kwargs.setdefault('interface', kwargs.pop('endpoint_type', + 'publicURL')) + + ignored = {'token': token, + 'auth_ref': auth_ref, + 'timeout': timeout != 600, + 'ca_file': ca_file, + 'cert_file': cert_file, + 'key_file': key_file, + 'insecure': insecure} + + dvars = [k for k, v in ignored.items() if v] + + if dvars: + LOG.warning('The following arguments are ignored when using ' + 'the session to construct a client: %s', + ', '.join(dvars)) + + return SessionClient(session=session, + os_ironic_api_version=os_ironic_api_version, + api_version_select_state=api_version_select_state, + max_retries=max_retries, + retry_interval=retry_interval, + **kwargs) diff --git a/ironicclient/common/i18n.py b/ironicclient/common/i18n.py index 90f289ae7..48b8d13ac 100644 --- a/ironicclient/common/i18n.py +++ b/ironicclient/common/i18n.py @@ -13,9 +13,19 @@ # License for the specific language governing permissions and limitations # under the License. -import oslo_i18n +from __future__ import annotations -_translators = oslo_i18n.TranslatorFactory(domain='ironicclient') +from typing import Callable -# The primary translation function using the well-known name "_" -_ = _translators.primary +try: + import oslo_i18n +except ImportError: + def _(msg: str) -> str: + return msg +else: + _translators: oslo_i18n.TranslatorFactory = oslo_i18n.TranslatorFactory( + domain="ironicclient" + ) + + # The primary translation function using the well-known name "_" + _: Callable[[str], str] = _translators.primary # type: ignore[no-redef] diff --git a/ironicclient/common/utils.py b/ironicclient/common/utils.py index dc5ac6236..33227771b 100644 --- a/ironicclient/common/utils.py +++ b/ironicclient/common/utils.py @@ -13,9 +13,8 @@ # License for the specific language governing permissions and limitations # under the License. -from __future__ import print_function - import argparse +import base64 import contextlib import gzip import json @@ -24,10 +23,11 @@ import subprocess import sys import tempfile +import time -from oslo_serialization import base64 +from cliff import columns from oslo_utils import strutils -import six +import yaml from ironicclient.common.i18n import _ from ironicclient import exc @@ -162,7 +162,7 @@ def convert_list_props_to_comma_separated(data, props=None): for prop in props: val = data.get(prop, None) if isinstance(val, list): - result[prop] = ', '.join(map(six.text_type, val)) + result[prop] = ', '.join(map(str, val)) return result @@ -215,7 +215,7 @@ def common_params_for_list(args, fields, field_labels): def common_filters(marker=None, limit=None, sort_key=None, sort_dir=None, - fields=None): + fields=None, detail=False, project=None, public=None): """Generate common filters for any list request. :param marker: entity ID from which to start returning entities. @@ -224,6 +224,9 @@ def common_filters(marker=None, limit=None, sort_key=None, sort_dir=None, :param sort_dir: direction of sorting: 'asc' or 'desc'. :param fields: a list with a specified set of fields of the resource to be returned. + :param detail: Boolean, True to return detailed information. This parameter + can be used for resources which accept 'detail' as a URL + parameter. :returns: list of string filters. """ filters = [] @@ -235,8 +238,14 @@ def common_filters(marker=None, limit=None, sort_key=None, sort_dir=None, filters.append('sort_key=%s' % sort_key) if sort_dir is not None: filters.append('sort_dir=%s' % sort_dir) + if project is not None: + filters.append('project=%s' % project) + if public is not None: + filters.append('public=True') if fields is not None: filters.append('fields=%s' % ','.join(fields)) + if detail: + filters.append('detail=True') return filters @@ -263,20 +272,30 @@ def make_configdrive(path): with tempfile.NamedTemporaryFile() as tmpfile: with tempfile.NamedTemporaryFile() as tmpzipfile: publisher = 'ironicclient-configdrive 0.1' - try: - p = subprocess.Popen(['genisoimage', '-o', tmpfile.name, - '-ldots', '-allow-lowercase', - '-allow-multidot', '-l', - '-publisher', publisher, - '-quiet', '-J', - '-r', '-V', 'config-2', - path], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE) - except OSError as e: + # NOTE(toabctl): Luckily, genisoimage, mkisofs and xorrisofs + # understand the same parameters which are currently used. + cmds = ['genisoimage', 'mkisofs', 'xorrisofs'] + for c in cmds: + try: + p = subprocess.Popen([c, '-o', tmpfile.name, + '-ldots', '-allow-lowercase', + '-allow-multidot', '-l', + '-publisher', publisher, + '-quiet', '-J', + '-r', '-V', 'config-2', + path], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + except OSError as e: + error = e + else: + error = None + break + if error: raise exc.CommandError( _('Error generating the config drive. Make sure the ' - '"genisoimage" tool is installed. Error: %s') % e) + '"genisoimage", "mkisofs", or "xorriso" tool is ' + 'installed. Error: %s') % error) stdout, stderr = p.communicate() if p.returncode != 0: @@ -287,12 +306,11 @@ def make_configdrive(path): # Compress file tmpfile.seek(0) - g = gzip.GzipFile(fileobj=tmpzipfile, mode='wb') - shutil.copyfileobj(tmpfile, g) - g.close() + with gzip.GzipFile(fileobj=tmpzipfile, mode='wb') as gz_file: + shutil.copyfileobj(tmpfile, gz_file) tmpzipfile.seek(0) - return base64.encode_as_bytes(tmpzipfile.read()) + return base64.b64encode(tmpzipfile.read()) def check_empty_arg(arg, arg_descriptor): @@ -364,7 +382,7 @@ def get_from_stdin(info_desc): def handle_json_or_file_arg(json_arg): """Attempts to read JSON argument from file or string. - :param json_arg: May be a file name containing the JSON, or + :param json_arg: May be a file name containing the YAML or JSON, or a JSON string. :returns: A list or dictionary parsed from JSON. :raises: InvalidAttribute if the argument cannot be parsed. @@ -373,16 +391,98 @@ def handle_json_or_file_arg(json_arg): if os.path.isfile(json_arg): try: with open(json_arg, 'r') as f: - json_arg = f.read().strip() + return yaml.safe_load(f) except Exception as e: - err = _("Cannot get JSON from file '%(file)s'. " + err = _("Cannot get JSON/YAML from file '%(file)s'. " "Error: %(err)s") % {'err': e, 'file': json_arg} raise exc.InvalidAttribute(err) try: json_arg = json.loads(json_arg) except ValueError as e: - err = (_("For JSON: '%(string)s', error: '%(err)s'") % - {'err': e, 'string': json_arg}) + err = (_("Value '%(string)s' is not a file and cannot be parsed " + "as JSON: '%(err)s'") % {'err': e, 'string': json_arg}) raise exc.InvalidAttribute(err) return json_arg + + +def poll(timeout, poll_interval, poll_delay_function, timeout_message): + if not isinstance(timeout, (int, float)) or timeout < 0: + raise ValueError(_('Timeout must be a non-negative number')) + + threshold = time.time() + timeout + poll_delay_function = (time.sleep if poll_delay_function is None + else poll_delay_function) + if not callable(poll_delay_function): + raise TypeError(_('poll_delay_function must be callable')) + + count = 0 + while not timeout or time.time() < threshold: + yield count + + poll_delay_function(poll_interval) + count += 1 + + if callable(timeout_message): + timeout_message = timeout_message() + raise exc.StateTransitionTimeout(timeout_message) + + +def handle_json_arg(json_arg, info_desc): + """Read a JSON argument from stdin, file or string. + + :param json_arg: May be a file name containing the JSON, a JSON string, or + '-' indicating that the argument should be read from standard input. + :param info_desc: A string description of the desired information + :returns: A list or dictionary parsed from JSON. + :raises: InvalidAttribute if the argument cannot be parsed. + """ + if json_arg == '-': + json_arg = get_from_stdin(info_desc) + if json_arg: + json_arg = handle_json_or_file_arg(json_arg) + return json_arg + + +def get_json_data(data): + """Check if the binary data is JSON and parse it if so. + + Only supports dictionaries. + """ + # We don't want to simply loads() a potentially large binary. Doing so, + # in my testing, is orders of magnitude (!!) slower than this process. + for idx in range(len(data)): + char = data[idx:idx + 1] + if char.isspace(): + continue + if char != b'{' and char != 'b[': + return None # not JSON, at least not JSON we care about + break # maybe JSON + + try: + return json.loads(data) + except ValueError: + return None + + +def format_hash(data): + if data is None: + return None + + output = "" + for s in sorted(data): + key_str = s + if isinstance(data[s], dict): + # NOTE(dtroyer): Only append the separator chars here, quoting + # is completely handled in the terminal case. + output = output + format_hash(data[s], prefix=key_str) + ", " + elif data[s] is not None: + output = output + key_str + "='" + str(data[s]) + "', " + else: + output = output + key_str + "=, " + return output[:-2] + + +class HashColumn(columns.FormattableColumn): + def human_readable(self): + return format_hash(self._value) diff --git a/ironicclient/exc.py b/ironicclient/exc.py index 85caf0138..aa81914c3 100644 --- a/ironicclient/exc.py +++ b/ironicclient/exc.py @@ -16,36 +16,38 @@ # NOTE(akurilin): This alias is left here since v.0.1.3 to support backwards # compatibility. -InvalidEndpoint = EndpointException -CommunicationError = ConnectionRefused -HTTPBadRequest = BadRequest -HTTPInternalServerError = InternalServerError -HTTPNotFound = NotFound -HTTPServiceUnavailable = ServiceUnavailable +InvalidEndpoint = exceptions.EndpointException +CommunicationError = exceptions.ConnectionRefused +HTTPBadRequest = exceptions.BadRequest +HTTPInternalServerError = exceptions.InternalServerError +HTTPNotFound = exceptions.NotFound +HTTPServiceUnavailable = exceptions.ServiceUnavailable -class AmbiguousAuthSystem(ClientException): +class AmbiguousAuthSystem(exceptions.ClientException): """Could not obtain token and endpoint using provided credentials.""" pass + # Alias for backwards compatibility AmbigiousAuthSystem = AmbiguousAuthSystem -class InvalidAttribute(ClientException): +class InvalidAttribute(exceptions.ClientException): pass -class StateTransitionFailed(ClientException): +class StateTransitionFailed(exceptions.ClientException): """Failed to reach a requested provision state.""" -class StateTransitionTimeout(ClientException): +class StateTransitionTimeout(exceptions.ClientException): """Timed out while waiting for a requested provision state.""" -def from_response(response, message=None, traceback=None, method=None, - url=None): +def from_response( # type: ignore[no-redef] + response, message=None, traceback=None, method=None, url=None +): """Return an HttpError instance based on response from httplib/requests.""" error_body = {} diff --git a/ironicclient/osc/plugin.py b/ironicclient/osc/plugin.py index f0b84e06c..09f3d78c6 100644 --- a/ironicclient/osc/plugin.py +++ b/ironicclient/osc/plugin.py @@ -19,37 +19,64 @@ import argparse import logging -from ironicclient.common import http from osc_lib import utils +from ironicclient.common import http + LOG = logging.getLogger(__name__) +CLIENT_CLASS = 'ironicclient.v1.client.Client' API_VERSION_OPTION = 'os_baremetal_api_version' API_NAME = 'baremetal' -LAST_KNOWN_API_VERSION = 31 +# NOTE(TheJulia) Latest known version tracking has been moved +# to the ironicclient/common/http.py file as the OSC commitment +# is latest known, and we should only store it in one location. +LAST_KNOWN_API_VERSION = http.LAST_KNOWN_API_VERSION +LATEST_VERSION = http.LATEST_VERSION + + API_VERSIONS = { - '1.%d' % i: 'ironicclient.v1.client.Client' + '1.%d' % i: CLIENT_CLASS for i in range(1, LAST_KNOWN_API_VERSION + 1) } -API_VERSIONS['1'] = API_VERSIONS[http.DEFAULT_VER] +API_VERSIONS['1'] = CLIENT_CLASS +# NOTE(dtantsur): flag to indicate that the requested version was "latest". +# Due to how OSC works we cannot just add "latest" to the list of supported +# versions - it breaks the major version detection. +OS_BAREMETAL_API_LATEST = True def make_client(instance): """Returns a baremetal service client.""" + requested_api_version = instance._api_version[API_NAME] + baremetal_client_class = utils.get_client_class( API_NAME, - instance._api_version[API_NAME], + requested_api_version, API_VERSIONS) LOG.debug('Instantiating baremetal client: %s', baremetal_client_class) - LOG.debug('Baremetal API version: %s', http.DEFAULT_VER) + LOG.debug('Baremetal API version: %s', + requested_api_version if not OS_BAREMETAL_API_LATEST + else "latest") + + if requested_api_version == '1': + # NOTE(dtantsur): '1' means 'the latest v1 API version'. Since we don't + # have other major versions, it's identical to 'latest'. + requested_api_version = LATEST_VERSION + allow_api_version_downgrade = True + else: + allow_api_version_downgrade = OS_BAREMETAL_API_LATEST client = baremetal_client_class( - os_ironic_api_version=instance._api_version[API_NAME], + os_ironic_api_version=requested_api_version, + # NOTE(dtantsur): enable re-negotiation of the latest version, if CLI + # latest is too high for the server we're talking to. + allow_api_version_downgrade=allow_api_version_downgrade, session=instance.session, region_name=instance._region_name, # NOTE(vdrok): This will be set as endpoint_override, and the Client # class will be able to do the version stripping if needed - endpoint=instance.get_endpoint_for_service_type( + endpoint_override=instance.get_endpoint_for_service_type( API_NAME, interface=instance.interface, region_name=instance._region_name ) @@ -62,27 +89,47 @@ def build_option_parser(parser): parser.add_argument( '--os-baremetal-api-version', metavar='', - default=utils.env( - 'OS_BAREMETAL_API_VERSION', - default=http.DEFAULT_VER), + default=_get_environment_version("latest"), choices=sorted( API_VERSIONS, key=lambda k: [int(x) for x in k.split('.')]) + ['latest'], action=ReplaceLatestVersion, - help='Baremetal API version, default=' + - http.DEFAULT_VER + - ' (Env: OS_BAREMETAL_API_VERSION). ' - '"latest" is the latest known API version', + help='Bare metal API version, default="latest" (the maximum version ' + 'supported by both the client and the server). ' + '(Env: OS_BAREMETAL_API_VERSION)', ) return parser +def _get_environment_version(default): + global OS_BAREMETAL_API_LATEST + env_value = utils.env('OS_BAREMETAL_API_VERSION') + if not env_value: + env_value = default + if env_value == 'latest': + env_value = LATEST_VERSION + else: + OS_BAREMETAL_API_LATEST = False + return env_value + + class ReplaceLatestVersion(argparse.Action): - """Replaces `latest` keyword by last known version.""" + """Replaces `latest` keyword by last known version. + + OSC cannot accept the literal "latest" as a supported API version as it + breaks the major version detection (OSC tries to load configuration options + from setuptools entrypoint openstack.baremetal.vlatest). This action + replaces "latest" with the latest known version, and sets the global + OS_BAREMETAL_API_LATEST flag appropriately. + """ def __call__(self, parser, namespace, values, option_string=None): - latest = values == 'latest' - if latest: - values = '1.%d' % LAST_KNOWN_API_VERSION - LOG.debug("Replacing 'latest' API version with the " - "latest known version '%s'", values) + global OS_BAREMETAL_API_LATEST + if values == 'latest': + values = LATEST_VERSION + # The default value of "True" may have been overridden due to + # non-empty OS_BAREMETAL_API_VERSION env variable. If a user + # explicitly requests "latest", we need to correct it. + OS_BAREMETAL_API_LATEST = True + else: + OS_BAREMETAL_API_LATEST = False setattr(namespace, self.dest, values) diff --git a/ironicclient/osc/v1/baremetal_allocation.py b/ironicclient/osc/v1/baremetal_allocation.py new file mode 100644 index 000000000..bb67dcece --- /dev/null +++ b/ironicclient/osc/v1/baremetal_allocation.py @@ -0,0 +1,378 @@ +# 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 itertools +import logging + +from osc_lib.command import command +from osc_lib import utils as oscutils + +from ironicclient.common.i18n import _ +from ironicclient.common import utils +from ironicclient import exc +from ironicclient.v1 import resource_fields as res_fields + + +class CreateBaremetalAllocation(command.ShowOne): + """Create a new baremetal allocation.""" + + log = logging.getLogger(__name__ + ".CreateBaremetalAllocation") + + def get_parser(self, prog_name): + parser = super(CreateBaremetalAllocation, self).get_parser(prog_name) + + parser.add_argument( + '--resource-class', + dest='resource_class', + help=_('Resource class to request.')) + parser.add_argument( + '--trait', + action='append', + dest='traits', + help=_('A trait to request. Can be specified multiple times.')) + parser.add_argument( + '--candidate-node', + action='append', + dest='candidate_nodes', + help=_('A candidate node for this allocation. Can be specified ' + 'multiple times. If at least one is specified, only the ' + 'provided candidate nodes are considered for the ' + 'allocation.')) + parser.add_argument( + '--name', + dest='name', + help=_('Unique name of the allocation.')) + parser.add_argument( + '--uuid', + dest='uuid', + help=_('UUID of the allocation.')) + parser.add_argument( + '--owner', + dest='owner', + help=_('Owner of the allocation.')) + parser.add_argument( + '--extra', + metavar="", + action='append', + help=_("Record arbitrary key/value metadata. " + "Can be specified multiple times.")) + parser.add_argument( + '--wait', + type=int, + dest='wait_timeout', + default=None, + metavar='', + const=0, + nargs='?', + help=_("Wait for the new allocation to become active. An error " + "is returned if allocation fails and --wait is used. " + "Optionally takes a timeout value (in seconds). The " + "default value is 0, meaning it will wait indefinitely.")) + parser.add_argument( + '--node', + help=_("Backfill this allocation from the provided node that has " + "already been deployed. Bypasses the normal allocation " + "process.")) + + return parser + + def take_action(self, parsed_args): + self.log.debug("take_action(%s)", parsed_args) + baremetal_client = self.app.client_manager.baremetal + + if not parsed_args.node and not parsed_args.resource_class: + raise exc.ClientException( + _('--resource-class is required except when --node is used')) + + field_list = ['name', 'uuid', 'extra', 'resource_class', 'traits', + 'candidate_nodes', 'node', 'owner'] + fields = dict((k, v) for (k, v) in vars(parsed_args).items() + if k in field_list and v is not None) + + fields = utils.args_array_to_dict(fields, 'extra') + allocation = baremetal_client.allocation.create(**fields) + if parsed_args.wait_timeout is not None: + allocation = baremetal_client.allocation.wait( + allocation.uuid, timeout=parsed_args.wait_timeout) + + data = dict([(f, getattr(allocation, f, '')) for f in + res_fields.ALLOCATION_DETAILED_RESOURCE.fields]) + + return self.dict2columns(data) + + +class ShowBaremetalAllocation(command.ShowOne): + """Show baremetal allocation details.""" + + log = logging.getLogger(__name__ + ".ShowBaremetalAllocation") + + def get_parser(self, prog_name): + parser = super(ShowBaremetalAllocation, self).get_parser(prog_name) + parser.add_argument( + "allocation", + metavar="", + help=_("UUID or name of the allocation")) + parser.add_argument( + '--fields', + nargs='+', + dest='fields', + metavar='', + action='append', + choices=res_fields.ALLOCATION_DETAILED_RESOURCE.fields, + default=[], + help=_("One or more allocation fields. Only these fields will be " + "fetched from the server.")) + return parser + + def take_action(self, parsed_args): + self.log.debug("take_action(%s)", parsed_args) + + baremetal_client = self.app.client_manager.baremetal + fields = list(itertools.chain.from_iterable(parsed_args.fields)) + fields = fields if fields else None + + allocation = baremetal_client.allocation.get( + parsed_args.allocation, fields=fields)._info + + allocation.pop("links", None) + return zip(*sorted(allocation.items())) + + +class ListBaremetalAllocation(command.Lister): + """List baremetal allocations.""" + + log = logging.getLogger(__name__ + ".ListBaremetalAllocation") + + def get_parser(self, prog_name): + parser = super(ListBaremetalAllocation, self).get_parser(prog_name) + parser.add_argument( + '--limit', + metavar='', + type=int, + help=_('Maximum number of allocations to return per request, ' + '0 for no limit. Default is the maximum number used ' + 'by the Baremetal API Service.')) + parser.add_argument( + '--marker', + metavar='', + help=_('Allocation UUID (for example, of the last allocation in ' + 'the list from a previous request). Returns the list of ' + 'allocations after this UUID.')) + parser.add_argument( + '--sort', + metavar="[:]", + help=_('Sort output by specified allocation fields and directions ' + '(asc or desc) (default: asc). Multiple fields and ' + 'directions can be specified, separated by comma.')) + parser.add_argument( + '--node', + metavar='', + help=_("Only list allocations of this node (name or UUID).")) + parser.add_argument( + '--resource-class', + metavar='', + help=_("Only list allocations with this resource class.")) + parser.add_argument( + '--state', + metavar='', + help=_("Only list allocations in this state.")) + parser.add_argument( + '--owner', + metavar='', + help=_("Only list allocations with this owner.")) + + # NOTE(dtantsur): the allocation API does not expose the 'detail' flag, + # but some fields are inconvenient to display in a table, so we emulate + # it on the client side. + display_group = parser.add_mutually_exclusive_group(required=False) + display_group.add_argument( + '--long', + default=False, + help=_("Show detailed information about the allocations."), + action='store_true') + display_group.add_argument( + '--fields', + nargs='+', + dest='fields', + metavar='', + action='append', + default=[], + choices=res_fields.ALLOCATION_DETAILED_RESOURCE.fields, + help=_("One or more allocation fields. Only these fields will be " + "fetched from the server. Can not be used when '--long' " + "is specified.")) + return parser + + def take_action(self, parsed_args): + self.log.debug("take_action(%s)", parsed_args) + client = self.app.client_manager.baremetal + + params = {} + if parsed_args.limit is not None and parsed_args.limit < 0: + raise exc.CommandError( + _('Expected non-negative --limit, got %s') % + parsed_args.limit) + params['limit'] = parsed_args.limit + params['marker'] = parsed_args.marker + for field in ('node', 'resource_class', 'state', 'owner'): + value = getattr(parsed_args, field) + if value is not None: + params[field] = value + + if parsed_args.long: + columns = res_fields.ALLOCATION_DETAILED_RESOURCE.fields + elif parsed_args.fields: + fields = itertools.chain.from_iterable(parsed_args.fields) + resource = res_fields.Resource(list(fields)) + columns = resource.fields + params['fields'] = columns + else: + columns = res_fields.ALLOCATION_RESOURCE.fields + + self.log.debug("params(%s)", params) + data = client.allocation.list(**params) + + data = oscutils.sort_items(data, parsed_args.sort) + + return (columns, + (oscutils.get_item_properties(s, columns) for s in data)) + + +class DeleteBaremetalAllocation(command.Command): + """Unregister baremetal allocation(s).""" + + log = logging.getLogger(__name__ + ".DeleteBaremetalAllocation") + + def get_parser(self, prog_name): + parser = super(DeleteBaremetalAllocation, self).get_parser(prog_name) + parser.add_argument( + "allocations", + metavar="", + nargs="+", + help=_("Allocations(s) to delete (name or UUID).")) + + return parser + + def take_action(self, parsed_args): + self.log.debug("take_action(%s)", parsed_args) + + baremetal_client = self.app.client_manager.baremetal + + failures = [] + for allocation in parsed_args.allocations: + try: + baremetal_client.allocation.delete(allocation) + print(_('Deleted allocation %s') % allocation) + except exc.ClientException as e: + failures.append(_("Failed to delete allocation " + "%(allocation)s: %(error)s") + % {'allocation': allocation, 'error': e}) + + if failures: + raise exc.ClientException("\n".join(failures)) + + +class SetBaremetalAllocation(command.Command): + """Set baremetal allocation properties.""" + + log = logging.getLogger(__name__ + ".SetBaremetalAllocation") + + def get_parser(self, prog_name): + parser = super(SetBaremetalAllocation, self).get_parser(prog_name) + + parser.add_argument( + "allocation", + metavar="", + help=_("Name or UUID of the allocation") + ) + parser.add_argument( + "--name", + metavar="", + help=_("Set the name of the allocation") + ) + parser.add_argument( + "--extra", + metavar="", + action="append", + help=_("Extra property to set on this allocation " + "(repeat option to set multiple extra properties)") + ) + return parser + + def take_action(self, parsed_args): + self.log.debug("take_action(%s)", parsed_args) + + baremetal_client = self.app.client_manager.baremetal + + properties = [] + if parsed_args.name: + properties.extend(utils.args_array_to_patch( + 'add', ["name=%s" % parsed_args.name])) + if parsed_args.extra: + properties.extend(utils.args_array_to_patch( + 'add', ["extra/" + x for x in parsed_args.extra])) + + if properties: + baremetal_client.allocation.update( + parsed_args.allocation, properties) + else: + self.log.warning("Please specify what to set.") + + +class UnsetBaremetalAllocation(command.Command): + """Unset baremetal allocation properties.""" + log = logging.getLogger(__name__ + ".UnsetBaremetalAllocation") + + def get_parser(self, prog_name): + parser = super(UnsetBaremetalAllocation, self).get_parser( + prog_name) + + parser.add_argument( + "allocation", + metavar="", + help=_("Name or UUID of the allocation") + ) + parser.add_argument( + "--name", + action="store_true", + default=False, + help=_("Unset the name of the allocation") + ) + parser.add_argument( + "--extra", + metavar="", + action='append', + help=_('Extra property to unset on this baremetal allocation ' + '(repeat option to unset multiple extra property).'), + ) + + return parser + + def take_action(self, parsed_args): + self.log.debug("take_action(%s)", parsed_args) + + baremetal_client = self.app.client_manager.baremetal + + properties = [] + if parsed_args.name: + properties.extend(utils.args_array_to_patch('remove', + ['name'])) + if parsed_args.extra: + properties.extend(utils.args_array_to_patch('remove', + ['extra/' + x for x in parsed_args.extra])) + + if properties: + baremetal_client.allocation.update(parsed_args.allocation, + properties) + else: + self.log.warning("Please specify what to unset.") diff --git a/ironicclient/osc/v1/baremetal_chassis.py b/ironicclient/osc/v1/baremetal_chassis.py index a84fc52f4..26e7bbfe2 100644 --- a/ironicclient/osc/v1/baremetal_chassis.py +++ b/ironicclient/osc/v1/baremetal_chassis.py @@ -162,7 +162,6 @@ def take_action(self, parsed_args): client = self.app.client_manager.baremetal columns = res_fields.CHASSIS_RESOURCE.fields - labels = res_fields.CHASSIS_RESOURCE.labels params = {} if parsed_args.limit is not None and parsed_args.limit < 0: @@ -174,13 +173,11 @@ def take_action(self, parsed_args): if parsed_args.long: params['detail'] = parsed_args.long columns = res_fields.CHASSIS_DETAILED_RESOURCE.fields - labels = res_fields.CHASSIS_DETAILED_RESOURCE.labels elif parsed_args.fields: params['detail'] = False fields = itertools.chain.from_iterable(parsed_args.fields) resource = res_fields.Resource(list(fields)) columns = resource.fields - labels = resource.labels params['fields'] = columns self.log.debug("params(%s)", params) @@ -188,9 +185,9 @@ def take_action(self, parsed_args): data = oscutils.sort_items(data, parsed_args.sort) - return (labels, + return (columns, (oscutils.get_item_properties(s, columns, formatters={ - 'Properties': oscutils.format_dict},) for s in data)) + 'Properties': utils.HashColumn},) for s in data)) class SetBaremetalChassis(command.Command): diff --git a/ironicclient/osc/v1/baremetal_conductor.py b/ironicclient/osc/v1/baremetal_conductor.py new file mode 100644 index 000000000..e66fb1dbc --- /dev/null +++ b/ironicclient/osc/v1/baremetal_conductor.py @@ -0,0 +1,143 @@ +# +# Copyright 2015 Red Hat, Inc. +# +# 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 itertools +import logging + +from osc_lib.command import command +from osc_lib import utils as oscutils + +from ironicclient.common.i18n import _ +from ironicclient.common import utils +from ironicclient import exc +from ironicclient.v1 import resource_fields as res_fields + + +class ListBaremetalConductor(command.Lister): + """List baremetal conductors""" + + log = logging.getLogger(__name__ + ".ListBaremetalNode") + + def get_parser(self, prog_name): + parser = super(ListBaremetalConductor, self).get_parser(prog_name) + parser.add_argument( + '--limit', + metavar='', + type=int, + help=_('Maximum number of conductors to return per request, ' + '0 for no limit. Default is the maximum number used ' + 'by the Baremetal API Service.') + ) + parser.add_argument( + '--marker', + metavar='', + help=_('Hostname of the conductor (for example, of the last ' + 'conductor in the list from a previous request). Returns ' + 'the list of conductors after this conductor.') + ) + parser.add_argument( + '--sort', + metavar="[:]", + help=_('Sort output by specified conductor fields and directions ' + '(asc or desc) (default: asc). Multiple fields and ' + 'directions can be specified, separated by comma.'), + ) + display_group = parser.add_mutually_exclusive_group(required=False) + display_group.add_argument( + '--long', + default=False, + help=_("Show detailed information about the conductors."), + action='store_true') + display_group.add_argument( + '--fields', + nargs='+', + dest='fields', + metavar='', + action='append', + default=[], + choices=res_fields.CONDUCTOR_DETAILED_RESOURCE.fields, + help=_("One or more conductor fields. Only these fields will be " + "fetched from the server. Can not be used when '--long' " + "is specified.")) + return parser + + def take_action(self, parsed_args): + self.log.debug("take_action(%s)", parsed_args) + client = self.app.client_manager.baremetal + + columns = res_fields.CONDUCTOR_RESOURCE.fields + + params = {} + if parsed_args.limit is not None and parsed_args.limit < 0: + raise exc.CommandError( + _('Expected non-negative --limit, got %s') % + parsed_args.limit) + params['limit'] = parsed_args.limit + params['marker'] = parsed_args.marker + if parsed_args.long: + params['detail'] = parsed_args.long + columns = res_fields.CONDUCTOR_DETAILED_RESOURCE.fields + elif parsed_args.fields: + params['detail'] = False + fields = itertools.chain.from_iterable(parsed_args.fields) + resource = res_fields.Resource(list(fields)) + columns = resource.fields + params['fields'] = columns + + self.log.debug("params(%s)", params) + data = client.conductor.list(**params) + + data = oscutils.sort_items(data, parsed_args.sort) + + return (columns, + (oscutils.get_item_properties(s, columns, formatters={ + 'Properties': utils.HashColumn},) for s in data)) + + +class ShowBaremetalConductor(command.ShowOne): + """Show baremetal conductor details""" + + log = logging.getLogger(__name__ + ".ShowBaremetalConductor") + + def get_parser(self, prog_name): + parser = super(ShowBaremetalConductor, self).get_parser(prog_name) + parser.add_argument( + "conductor", + metavar="", + help=_("Hostname of the conductor")) + parser.add_argument( + '--fields', + nargs='+', + dest='fields', + metavar='', + action='append', + choices=res_fields.CONDUCTOR_DETAILED_RESOURCE.fields, + default=[], + help=_("One or more conductor fields. Only these fields will be " + "fetched from the server.")) + return parser + + def take_action(self, parsed_args): + self.log.debug("take_action(%s)", parsed_args) + + baremetal_client = self.app.client_manager.baremetal + fields = list(itertools.chain.from_iterable(parsed_args.fields)) + fields = fields if fields else None + conductor = baremetal_client.conductor.get( + parsed_args.conductor, fields=fields)._info + conductor.pop("links", None) + + return self.dict2columns(conductor) diff --git a/ironicclient/osc/v1/baremetal_create.py b/ironicclient/osc/v1/baremetal_create.py index c16db10b9..5d23ddbf2 100644 --- a/ironicclient/osc/v1/baremetal_create.py +++ b/ironicclient/osc/v1/baremetal_create.py @@ -10,69 +10,28 @@ # License for the specific language governing permissions and limitations # under the License. -import argparse import logging +from osc_lib.command import command + from ironicclient.common.i18n import _ -from ironicclient import exc -from ironicclient.osc.v1 import baremetal_node from ironicclient.v1 import create_resources -class CreateBaremetal(baremetal_node.CreateBaremetalNode): - """Create resources from files or Register a new node (DEPRECATED). - - Create resources from files (by only specifying the files) or register - a new node by specifying one or more optional arguments (DEPRECATED, - use 'openstack baremetal node create' instead). - """ +class CreateBaremetal(command.Command): + """Create resources from files""" log = logging.getLogger(__name__ + ".CreateBaremetal") - def get_description(self): - return _("Create resources from files (by only specifying the files) " - "or register a new node by specifying one or more optional " - "arguments (DEPRECATED, use 'openstack baremetal node " - "create' instead)") - - # TODO(vdrok): Remove support for new node creation after 11-July-2017 - # during the 'Queens' cycle. def get_parser(self, prog_name): parser = super(CreateBaremetal, self).get_parser(prog_name) - # NOTE(vdrok): It is a workaround to allow --driver to be optional for - # openstack create command while creation of nodes via this command is - # not removed completely - parser = argparse.ArgumentParser(parents=[parser], - conflict_handler='resolve', - description=self.__doc__) - parser.add_argument( - '--driver', - metavar='', - help=_('Specify this and any other optional arguments if you want ' - 'to create a node only. Note that this is deprecated; ' - 'please use "openstack baremetal node create" instead.')) + parser.add_argument( - "resource_files", metavar="", default=[], nargs="*", + "resource_files", metavar="", nargs="+", help=_("File (.yaml or .json) containing descriptions of the " - "resources to create. Can be specified multiple times. If " - "you want to create resources, only specify the files. Do " - "not specify any of the optional arguments.")) + "resources to create. Can be specified multiple times.")) return parser def take_action(self, parsed_args): - if parsed_args.driver: - self.log.warning("This command is deprecated. Instead, use " - "'openstack baremetal node create'.") - return super(CreateBaremetal, self).take_action(parsed_args) - if not parsed_args.resource_files: - raise exc.ValidationError(_( - "If --driver is not supplied to openstack create command, " - "it is considered that it will create ironic resources from " - "one or more .json or .yaml files, but no files provided.")) create_resources.create_resources(self.app.client_manager.baremetal, parsed_args.resource_files) - # NOTE(vdrok): CreateBaremetal is still inherited from ShowOne class, - # which requires the return value of the function to be of certain - # type, leave this workaround until creation of nodes is removed and - # then change it so that this inherits from command.Command - return tuple(), tuple() diff --git a/ironicclient/osc/v1/baremetal_deploy_template.py b/ironicclient/osc/v1/baremetal_deploy_template.py new file mode 100644 index 000000000..aaec26685 --- /dev/null +++ b/ironicclient/osc/v1/baremetal_deploy_template.py @@ -0,0 +1,342 @@ +# 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 itertools +import json +import logging + +from osc_lib.command import command +from osc_lib import utils as oscutils + +from ironicclient.common.i18n import _ +from ironicclient.common import utils +from ironicclient import exc +from ironicclient.v1 import resource_fields as res_fields + + +_DEPLOY_STEPS_HELP = _( + "The deploy steps. May be the path to a YAML file containing the deploy " + "steps; OR '-', with the deploy steps being read from standard " + "input; OR a JSON string. The value should be a list of deploy-step " + "dictionaries; each dictionary should have keys 'interface', 'step', " + "'args' and 'priority'.") + + +class CreateBaremetalDeployTemplate(command.ShowOne): + """Create a new deploy template""" + + log = logging.getLogger(__name__ + ".CreateBaremetalDeployTemplate") + + def get_parser(self, prog_name): + parser = super(CreateBaremetalDeployTemplate, self).get_parser( + prog_name) + + parser.add_argument( + 'name', + metavar='', + help=_('Unique name for this deploy template. Must be a valid ' + 'trait name') + ) + parser.add_argument( + '--uuid', + dest='uuid', + metavar='', + help=_('UUID of the deploy template.')) + parser.add_argument( + '--extra', + metavar="", + action='append', + help=_("Record arbitrary key/value metadata. " + "Can be specified multiple times.")) + parser.add_argument( + '--steps', + metavar="", + required=True, + help=_DEPLOY_STEPS_HELP + ) + return parser + + def take_action(self, parsed_args): + self.log.debug("take_action(%s)", parsed_args) + baremetal_client = self.app.client_manager.baremetal + + steps = utils.handle_json_arg(parsed_args.steps, 'deploy steps') + + field_list = ['name', 'uuid', 'extra'] + fields = dict((k, v) for (k, v) in vars(parsed_args).items() + if k in field_list and v is not None) + fields = utils.args_array_to_dict(fields, 'extra') + template = baremetal_client.deploy_template.create(steps=steps, + **fields) + + data = dict([(f, getattr(template, f, '')) for f in + res_fields.DEPLOY_TEMPLATE_DETAILED_RESOURCE.fields]) + + return self.dict2columns(data) + + +class ShowBaremetalDeployTemplate(command.ShowOne): + """Show baremetal deploy template details.""" + + log = logging.getLogger(__name__ + ".ShowBaremetalDeployTemplate") + + def get_parser(self, prog_name): + parser = super(ShowBaremetalDeployTemplate, self).get_parser(prog_name) + parser.add_argument( + "template", + metavar="