Compare commits

..

30 Commits
4.3.0 ... 4.9.0

Author SHA1 Message Date
Zuul
fe8aa16dd7 Merge "Add action update command to support skipping actions manually" 2025-08-28 19:37:24 +00:00
Alfredo Moralejo
d17bfa04ad Add action update command to support skipping actions manually
This commit introduces the "openstack optimize action update" command
that allows cloud admins to manually update action states. The only
current use case is to mark actions as SKIPPED before starting an
action plan.

Additionally, the option `--reason` can be used to provide a text that
will be stored as part of the status_message field.

Command usage:
  openstack optimize action update --state SKIPPED --reason "reason" <uuid>

The feature requires Watcher API microversion 1.5 or higher and includes
automatic version checking.

In order to assert specific strings in stderr, I'm enabling
`merge_stderr` option by default in execute. It's totally backwards
compatible, so I'm not parametrizing it.

Implements: blueprint add-skip-actions

Assisted-By: Claude (claude-sonnet-4)

Depends-On: https://review.opendev.org/c/openstack/watcher/+/955753/
Change-Id: Ice88c0ab58c0cfd784c707620da89a891055ffc2
Signed-off-by: Alfredo Moralejo <amoralej@redhat.com>
2025-08-28 14:44:46 -03:00
Zuul
02fcffb384 Merge "Drop explicit dependency on python-subunit" 2025-08-28 15:07:05 +00:00
Goutham Pacha Ravi
489d8c3e74 Replace CLA with DCO
Change-Id: I4581a2ef78ebf00e803c9f81ceb8a0625131bc40
Signed-off-by: Goutham Pacha Ravi <gouthampravi@gmail.com>
2025-08-01 14:49:17 +00:00
Sean Mooney
adad31627f finalize python 3.9 support removal
The last release of openstack to support python 3.9
was 2025.1 (epoxy), with this change watcherclient now
requires 3.10.

Change-Id: Iae0b4e113e149bca82c61c478c3cdf269057d0aa
Signed-off-by: Sean Mooney <work@seanmooney.info>
2025-07-21 17:49:04 +00:00
Takashi Kajinami
d5df64d435 Drop explicit dependency on python-subunit
It is no longer directly used by any test code in this repository since
we switched to stestr[1].

It is now installed as a dependency of stestr.

[1] 1086b23454

Change-Id: I24cdf2affb06939d0c610abe7c5c97672be4124f
2025-06-11 00:01:47 +09:00
Zuul
b18e1b2ab4 Merge "Remove unnecessary +x mode" 2025-05-26 15:39:06 +00:00
Sean Mooney
3fa37f817e add pyproject.toml to support pip 23.1
pip 23.1 removed the "setup.py install" fallback for projects
that do not have pyproject.toml and now uses a pyproject.toml
which is vendored in pip.
To address that, this change adds the minimal pyproject.toml
to enable pbr to be properly used to build editable wheels.

This is required to support installing devstack on
centos stream 9 and related distros with GLOBAL_VENV=True
Without this change the wsgi scripts are not generated in
editable mode. i.e. pip install -e /opt/stack/keystone

See https://pip.pypa.io/en/stable/news/#v23-1
and https://github.com/pypa/pip/issues/8368 for more
details on the removal of the fallback support.

setuptools v64.0.0 is used to support editable installs
via its PEP-660 implmentation
https://github.com/pypa/setuptools/pull/3488

Change-Id: Ia68aa799a0c4daaffd59c6faa2b59a5dda4015a8
2025-05-14 13:51:43 +01:00
Takashi Kajinami
3d5546703a Remove unnecessary +x mode
These files are not actually executable.

Change-Id: Id8ed4d81e160c8085c735971b5daff1170e0bfa0
2025-04-16 12:14:54 +09:00
Zuul
0d322f4b4f Merge "tox: Drop envdir" 2025-04-14 11:20:30 +00:00
Zuul
b85b9dc30c Merge "Move functional tests from watcher_tempest_plugin to watcherclient" 2025-03-19 00:59:02 +00:00
Chandan Kumar (raukadah)
e9fc2c8851 Move functional tests from watcher_tempest_plugin to watcherclient
Functional tests are meant to validate the openstack optimize cli
functionality. The code was kept in watcher-tempest-plugin repo.

Tempest plugin repo was meant to store tempest related api and
scenario tests, not functional tests. This patch moves the code
from watcher-tempest-plugin to watcherclient repo.

It also adds:
 - tox target for running functional tests
 - New zuul job based on devstack-tox-functional to run functional
   tests.

Note: Now functional tests are running via tox and OS_* variable.
That's why It also drops tempest credentials factory to get creds from
tempest.conf.

Related-bug: #2100741

Change-Id: Ibf25c9cd6b8cd6b228f759d7393af5fe6a357d7f
Signed-off-by: Chandan Kumar (raukadah) <chkumar@redhat.com>
2025-03-12 10:14:04 +05:30
Takashi Kajinami
65d1a9cbff tox: Drop envdir
tox now always recreates an env although the env is shared using envdir
options.
~~~
$ tox -e genpolicy
genpolicy: recreate env because env type changed from
{'name': 'genconfig', 'type': 'VirtualEnvRunner'} to
{'name': 'genpolicy', 'type': 'VirtualEnvRunner'}
~~~

According to the maintainer of tox, this functionality is not intended
to be supported.
https://github.com/tox-dev/tox/issues/425#issuecomment-1011944293

Change-Id: I1c4a0780f890b679b83c7ff8d87c549cee3cdd58
2025-02-11 13:30:33 +09:00
Zuul
edbc62c502 Merge "Python 3.12: do not use ssl.wrap_socket" 2025-01-09 23:09:40 +00:00
Zuul
cde38f53af Merge "Update python versions, drop py3.8" 2025-01-09 23:09:38 +00:00
Zuul
2bd17ffadb Merge "If endpoint ends with 1 client removes it" 2025-01-09 23:05:34 +00:00
Zuul
d801e89157 Merge "Drop unused tempest from test requirements" 2024-12-21 01:35:09 +00:00
Zuul
2af23e860d Merge "Make watcherclient/shell.py reproducible" 2024-12-06 14:15:22 +00:00
Zuul
4317c283cc Merge "Get rid of distutils" 2024-12-06 13:28:17 +00:00
Mitya Eremeev
5359e7b4ec If endpoint ends with 1 client removes it
e.g. Watcher endpoint is "127.0.0.1:8081",
Watcher client sends http request to "127.0.0.1:808"

Closes-Bug: #2052779
Change-Id: I78631c8a13ff73a236f3bfadd7f4258b254b6113
2024-12-05 20:23:07 +03:00
Martin Kopec
536ed330e8 Update python versions, drop py3.8
The current testing runtime [1] states testing from py3.9
to 3.12. The patch updates setup.cfg to reflect the correct
python versions.

The patch also drops python 3.8 support following [2].

[1] https://governance.openstack.org/tc/reference/runtimes/2025.1.html
[2] https://lists.openstack.org/archives/list/openstack-discuss@lists.openstack.org/thread/FOWV4UQZTH4DPDA67QDEROAESYU5Z3LE/

Change-Id: Id3d9a1cce51f064931a5b3310f301e09118f65b3
2024-11-01 14:29:47 +01:00
Takashi Kajinami
a2e4e22ce8 Drop unused tempest from test requirements
None of the tests implemented in this repository depend on tempest.

Change-Id: Ie06e1f3e3990ba5d167f5331cb12b845ab809e6b
2024-10-12 00:07:02 +09:00
Takashi Kajinami
be7ee7347a Get rid of distutils
... because it was already removed from Python 3.12 .

Change-Id: Ie809a48ce49d3fd6139c55109c9ed92b852ad41e
2024-10-02 23:24:03 +09:00
Takashi Kajinami
cd49282297 Bump hacking
hacking 3.0.x is quite old. Bump it to the current latest version.

Change-Id: I546d263ff091c47e5e97066499bc3711f4f760c6
2024-09-23 11:56:16 +09:00
Cyril Roelandt
79fe6b8fee Python 3.12: do not use ssl.wrap_socket
The ssl.wrap_socket method has been removed in 3.12.
SSLContext.wrap_socket should now be used.

Change-Id: I6e4f6848c07f7f9c1937ebde433a85ccfde7ba6a
2024-07-03 17:19:02 +02:00
Zuul
56e47c38e2 Merge "Remove untested lower-constraints.txt" 2024-03-02 08:33:27 +00:00
Zuul
04b6cc9a04 Merge "Update python classifier in setup.cfg" 2024-03-02 08:33:26 +00:00
Ghanshyam Mann
9aace80a9b Update python classifier in setup.cfg
As per the current release tested runtime, we test
python version from 3.8 to 3.11 so updating the
same in python classifier in setup.cfg

Change-Id: I3a938c4d27f987f4fa1a60d41baf9da74b32a78f
2024-01-09 19:22:07 -08:00
Takashi Kajinami
63d305ca25 Remove untested lower-constraints.txt
The lower constraints job was already removed[1] and the file content
is no longer tested.

[1] 52a3fd062d

Change-Id: Ife61e9de574bc0fd78bd733a33e41ac6cb48d12d
2024-01-08 23:45:26 +09:00
Thomas Goirand
834ab29878 Make watcherclient/shell.py reproducible
Hi,

Whilst working on the Reproducible Builds effort [0] we noticed that
python-watcherclient could not be built reproducibly.

This is because the documentation generates automatic documentation for
the "main()" entrypoint method's arguments, one of which is "sys.argv".
During document generation this results in documentation examples like:

   def main(argv=['-b', 'html', 'doc/source',
      '«ABSOLUTE_BUILD_DIR»/debian/python-watcherclient-doc/usr/.../html']):

… etc. Patch attached that sets "None" instead but retains the existing
fallback logic.

 [0] https://reproducible-builds.org/

Please note that this was reported in the Debian tracker:
https://bugs.debian.org/960607

and that the fix was applied to the Debian package.

Change-Id: I502bb2d11d90ce4c46c14904a8c048ea824f11d5
2021-12-28 19:33:55 +01:00
37 changed files with 1375 additions and 131 deletions

View File

@@ -1,3 +1,30 @@
- job:
name: python-watcherclient-functional
parent: devstack-tox-functional
timeout: 7200
required-projects:
- openstack/watcher
- openstack/python-watcherclient
vars:
# Run cross-project watcherclient functional tests on watcher repo.
zuul_work_dir: src/opendev.org/openstack/python-watcherclient
openrc_enable_export: true
devstack_plugins:
watcher: https://opendev.org/openstack/watcher
devstack_services:
watcher-api: true
watcher-decision-engine: true
watcher-applier: true
s-account: false
s-container: false
s-object: false
s-proxy: false
irrelevant-files:
- ^.*\.rst$
- ^doc/.*$
- ^releasenotes/.*$
- project:
templates:
- openstack-cover-jobs
@@ -7,4 +34,4 @@
- openstackclient-plugin-jobs
check:
jobs:
- watcherclient-tempest-functional
- python-watcherclient-functional

View File

@@ -24,7 +24,7 @@ The watcher client is the command-line interface (CLI) for the
Infrastructure Optimization service (watcher) API
and its extensions.
This chapter documents :command:`watcher` version ``1.3.0``.
This chapter documents watcherclient version ``4.9.0``.
For help on a specific :command:`watcher` command, enter:
@@ -214,6 +214,37 @@ Show detailed information about a given action.
``-h, --help``
show this help message and exit
.. _watcher_action_update:
watcher action update
---------------------
.. code-block:: console
usage: watcher action update [-h] [-f {html,json,shell,table,value,yaml}]
[-c COLUMN] [--max-width <integer>] [--fit-width]
[--print-empty] [--noindent] [--prefix PREFIX]
[--state <state>] [--reason <reason>] <action>
Update action command.
**Positional arguments:**
``<action>``
UUID of the action
**Optional arguments:**
``-h, --help``
show this help message and exit
``--state <state>``
New state for the action (e.g., SKIPPED)
``--reason <reason>``
Reason for the action state change.
.. _watcher_actionplan_cancel:
watcher actionplan cancel

View File

@@ -8,19 +8,24 @@ If you're interested in contributing to the python-watcherclient project,
the following will help get you started.
Contributor License Agreement
-----------------------------
Developer Certificate of Origin
-------------------------------
.. index::
single: license; agreement
In order to contribute to the python-watcherclient project, you need to have
signed OpenStack's contributor's agreement.
In order to contribute to the python-watcherclient 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/infra/manual/developers.html
* https://wiki.openstack.org/CLA
* https://docs.openstack.org/contributors/common/dco.html
LaunchPad Project
-----------------

View File

@@ -1,82 +0,0 @@
alabaster==0.7.10
appdirs==1.4.3
asn1crypto==0.23.0
certifi==2018.1.18
cffi==1.14.0
chardet==3.0.4
cliff==2.11.0
cmd2==0.8.2
coverage==4.0
cryptography==2.7
debtcollector==1.19.0
decorator==4.2.1
deprecation==2.0
docutils==0.11
dogpile.cache==0.6.5
dulwich==0.15.0
extras==1.0.0
fasteners==0.7.0
fixtures==3.0.0
flake8==2.5.5
hacking==0.12.0
idna==2.6
imagesize==0.7.1
iso8601==0.1.12
Jinja2==2.10
jmespath==0.9.3
jsonpatch==1.21
jsonpointer==2.0
jsonschema==2.6.0
keystoneauth1==3.4.0
linecache2==1.0.0
MarkupSafe==1.0
mccabe==0.2.1
monotonic==1.4
msgpack-python==0.4.0
munch==2.2.0
netaddr==0.7.19
netifaces==0.10.6
openstacksdk==0.12.0
os-client-config==1.29.0
os-service-types==1.2.0
os-testr==1.0.0
osc-lib==1.10.0
oslo.concurrency==3.25.0
oslo.config==5.2.0
oslo.context==2.19.2
oslo.i18n==3.20.0
oslo.log==3.36.0
oslo.serialization==2.18.0
oslo.utils==3.36.0
oslotest==3.2.0
packaging==17.1
paramiko==2.0.0
pbr==3.1.1
pep8==1.5.7
pyasn1==0.1.8
pycparser==2.18
pyflakes==0.8.1
Pygments==2.2.0
pyinotify==0.9.6
pyparsing==2.2.0
pyperclip==1.6.0
python-dateutil==2.5.3
python-mimeparse==1.6.0
python-subunit==1.0.0
pytz==2018.3
PyYAML==3.13
requests==2.18.4
requestsexceptions==1.4.0
rfc3986==0.3.1
simplejson==3.13.2
six==1.11.0
snowballstemmer==1.2.1
stestr==2.0.0
stevedore==1.28.0
tempest==17.1.0
testscenarios==0.4
testtools==2.2.0
traceback2==1.4.0
unittest2==1.1.0
urllib3==1.22
wrapt==1.10.11

3
pyproject.toml Normal file
View File

@@ -0,0 +1,3 @@
[build-system]
requires = ["pbr>=6.0.0", "setuptools>=64.0.0"]
build-backend = "pbr.build"

View File

@@ -0,0 +1,24 @@
---
features:
- |
Added support for updating action state through the new
``openstack optimize action update`` command. This feature allows
operators to manually change action states. The command
supports the following options:
* ``--state <state>`` - New state for the action (required)
* ``--reason <reason>`` - Optional reason for the state change
Currently, the only use case for this update is to Skip an action
before starting an Action Plan with an optional reason by setting
the state to SKIPPED:
$ openstack optimize action update --state SKIPPED --reason "Manual skip" <action-uuid>
This feature requires Watcher API microversion 1.5 or higher.
upgrade:
- |
The maximum supported API version has been increased from 1.1 to 1.5
to support the new action update functionality. This change maintains
full backward compatibility with existing deployments.

View File

@@ -0,0 +1,6 @@
---
upgrade:
- |
watcher client now requires python 3.10 or newer.
The last release to support ``3.9`` was ``2025.1``.
Please ensure you have a supported python version before upgrading.

View File

@@ -6,7 +6,7 @@ description_file =
author = OpenStack
author_email = openstack-discuss@lists.openstack.org
home_page = https://docs.openstack.org/python-watcherclient/latest/
python_requires = >=3.8
python_requires = >=3.10
classifier =
Environment :: OpenStack
Intended Audience :: Information Technology
@@ -15,8 +15,9 @@ classifier =
Operating System :: POSIX :: Linux
Programming Language :: Python
Programming Language :: Python :: 3
Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.9
Programming Language :: Python :: 3.10
Programming Language :: Python :: 3.11
Programming Language :: Python :: 3.12
[files]
packages =
@@ -59,6 +60,7 @@ openstack.infra_optim.v1 =
optimize_action_show = watcherclient.v1.action_shell:ShowAction
optimize_action_list = watcherclient.v1.action_shell:ListAction
optimize_action_update = watcherclient.v1.action_shell:UpdateAction
optimize_scoringengine_show = watcherclient.v1.scoring_engine_shell:ShowScoringEngine
optimize_scoringengine_list = watcherclient.v1.scoring_engine_shell:ListScoringEngine
@@ -98,6 +100,7 @@ watcherclient.v1 =
action_show = watcherclient.v1.action_shell:ShowAction
action_list = watcherclient.v1.action_shell:ListAction
action_update = watcherclient.v1.action_shell:UpdateAction
scoringengine_show = watcherclient.v1.scoring_engine_shell:ShowScoringEngine
scoringengine_list = watcherclient.v1.scoring_engine_shell:ListScoringEngine

View File

@@ -1,8 +1,7 @@
coverage!=4.4,>=4.0 # Apache-2.0
fixtures>=3.0.0 # Apache-2.0/BSD
hacking>=3.0.1,<3.1.0 # Apache-2.0
hacking>=7.0.0,<7.1.0 # Apache-2.0
oslotest>=3.2.0 # Apache-2.0
python-subunit>=1.0.0 # Apache-2.0/BSD
stestr>=2.0.0 # Apache-2.0
testscenarios>=0.4 # Apache-2.0/BSD
testtools>=2.2.0 # MIT

View File

@@ -7,8 +7,6 @@ usedevelop = True
passenv = ZUUL_CACHE_DIR
REQUIREMENTS_PIP_LOCATION
install_command = pip install {opts} {packages}
setenv =
VIRTUAL_ENV={envdir}
deps =
-c{env:TOX_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/master}
-r{toxinidir}/test-requirements.txt
@@ -49,7 +47,6 @@ commands = sphinx-build -W -b html doc/source doc/build/html
[testenv:pdf-docs]
basepython = python3
envdir = {toxworkdir}/docs
deps = {[testenv:docs]deps}
allowlist_externals =
rm
@@ -77,3 +74,8 @@ commands = python setup.py bdist_wheel
[hacking]
import_exceptions = watcherclient._i18n
[testenv:functional]
passenv = OS_*
commands =
stestr --test-path=./watcherclient/tests/client_functional/ run --concurrency=1 {posargs}

View File

@@ -32,4 +32,4 @@ API_MIN_VERSION = api_versioning.APIVersion("1.0")
# when client supported the max version, and bumped sequentially, otherwise
# the client may break due to server side new version may include some
# backward incompatible change.
API_MAX_VERSION = api_versioning.APIVersion("1.1")
API_MAX_VERSION = api_versioning.APIVersion("1.5")

View File

@@ -28,6 +28,7 @@ if not LOG.handlers:
MINOR_1_START_END_TIMING = '1.1'
MINOR_2_FORCE_AUDIT = '1.2'
MINOR_5_ACTION_UPDATE = '1.5'
HEADER_NAME = "OpenStack-API-Version"
# key is a deprecated version and value is an alternative version.
DEPRECATED_VERSIONS = {}
@@ -54,6 +55,15 @@ def launch_audit_forced(requested_version):
APIVersion(MINOR_2_FORCE_AUDIT))
def action_update_supported(requested_version):
"""Check if we should support action update functionality.
Version 1.5 of the API added support for updating action state.
"""
return (APIVersion(requested_version) >=
APIVersion(MINOR_5_ACTION_UPDATE))
class APIVersion(object):
"""This class represents an API Version Request.

View File

@@ -14,13 +14,13 @@
# under the License.
import copy
from distutils import version
import functools
import hashlib
import http.client
import io
import logging
import os
import re
import socket
import ssl
import textwrap
@@ -41,7 +41,7 @@ from watcherclient import exceptions
# Record the latest version that this client was tested with.
DEFAULT_VER = '1.latest'
# Minor version 4 for adding webhook API
LAST_KNOWN_API_VERSION = 4
LAST_KNOWN_API_VERSION = 5
LATEST_VERSION = '1.{}'.format(LAST_KNOWN_API_VERSION)
LOG = logging.getLogger(__name__)
@@ -62,7 +62,7 @@ SUPPORTED_ENDPOINT_SCHEME = ('http', 'https')
def _trim_endpoint_api_version(url):
"""Trim API version and trailing slash from endpoint."""
return url.rstrip('/').rstrip(API_VERSION)
return re.sub(f'{API_VERSION}$', '', url.rstrip('/'))
def _extract_error_json(body):
@@ -123,16 +123,19 @@ class VersionNegotiationMixin(object):
% {'req': self.os_infra_optim_api_version,
'min': min_ver, 'max': max_ver}))
negotiated_ver = str(
min(version.StrictVersion(self.os_infra_optim_api_version),
version.StrictVersion(max_ver)))
negotiated_ver = api_versioning.APIVersion(
self.os_infra_optim_api_version)
min_ver = api_versioning.APIVersion(min_ver)
max_ver = api_versioning.APIVersion(max_ver)
if negotiated_ver > max_ver:
negotiated_ver = max_ver
if negotiated_ver < 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'
self.os_infra_optim_api_version = negotiated_ver
LOG.debug('Negotiated API version is %s', negotiated_ver)
self.os_infra_optim_api_version = negotiated_ver.get_string()
LOG.debug('Negotiated API version is %s', negotiated_ver.get_string())
return negotiated_ver
@@ -434,11 +437,6 @@ class VerifiedHTTPSConnection(http.client.HTTPSConnection):
"""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)
@@ -446,17 +444,21 @@ class VerifiedHTTPSConnection(http.client.HTTPSConnection):
self.sock = sock
self._tunnel()
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
if self.insecure is True:
kwargs = {'cert_reqs': ssl.CERT_NONE}
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
else:
kwargs = {'cert_reqs': ssl.CERT_REQUIRED, 'ca_certs': self.ca_file}
context.load_verify_locations(self.ca_file)
if self.cert_file:
kwargs['certfile'] = self.cert_file
if self.key_file:
kwargs['keyfile'] = self.key_file
context.load_cert_chain(self.cert_file, self.key_file)
else:
context.load_cert_chain(self.cert_file)
self.sock = ssl.wrap_socket(sock, **kwargs)
self.sock = context.wrap_socket(sock)
@staticmethod
def get_system_ca_file():

View File

@@ -203,7 +203,9 @@ class WatcherShell(app.App):
LOG.info("END return value: %s", ret_val)
def main(argv=sys.argv[1:]):
def main(argv=None):
if argv is None:
argv = sys.argv[1:]
watcher_app = WatcherShell()
return watcher_app.run(argv)

View File

@@ -0,0 +1,141 @@
# 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 os
import re
import shlex
import subprocess
import testtools
from tempest.lib.cli import output_parser
from tempest.lib import exceptions
def credentials():
# You can get credentials from OS environment.
creds_dict = {
'--os-username': os.environ.get('OS_USERNAME'),
'--os-password': os.environ.get('OS_PASSWORD'),
'--os-project-name': os.environ.get('OS_PROJECT_NAME'),
'--os-auth-url': os.environ.get('OS_AUTH_URL'),
'--os-project-domain-name': os.environ.get('OS_PROJECT_DOMAIN_ID'),
'--os-user-domain-name': os.environ.get('OS_USER_DOMAIN_ID'),
}
return [x for sub in creds_dict.items() for x in sub]
def execute(cmd, fail_ok=False, merge_stderr=True):
"""Executes specified command for the given action."""
cmdlist = shlex.split(cmd)
cmdlist.extend(credentials())
stdout = subprocess.PIPE
stderr = subprocess.STDOUT if merge_stderr else subprocess.PIPE
proc = subprocess.Popen(cmdlist, stdout=stdout, stderr=stderr)
result, result_err = proc.communicate()
result = result.decode('utf-8')
if not fail_ok and proc.returncode != 0:
raise exceptions.CommandFailed(proc.returncode, cmd, result,
result_err)
return result
class TestCase(testtools.TestCase):
delimiter_line = re.compile(r'^\+\-[\+\-]+\-\+$')
api_version = 1.0
@classmethod
def watcher(cls, cmd, fail_ok=False):
"""Executes watcherclient command for the given action."""
return execute(
'openstack optimize --os-infra-optim-api-version {0} {1}'.format(
cls.api_version, cmd), fail_ok=fail_ok)
@classmethod
def get_opts(cls, fields, format='value'):
return ' -f {0} {1}'.format(format,
' '.join(['-c ' + it for it in fields]))
@classmethod
def assertOutput(cls, expected, actual):
if expected != actual:
raise Exception('{0} != {1}'.format(expected, actual))
@classmethod
def assertInOutput(cls, expected, actual):
if expected not in actual:
raise Exception('{0} not in {1}'.format(expected, actual))
def assert_table_structure(self, items, field_names):
"""Verify that all items have keys listed in field_names."""
for item in items:
for field in field_names:
self.assertIn(field, item)
def assert_show_fields(self, items, field_names):
"""Verify that all items have keys listed in field_names."""
for item in items:
for key in item.keys():
self.assertIn(key, field_names)
def assert_show_structure(self, items, field_names):
"""Verify that all field_names listed in keys of all items."""
if isinstance(items, list):
o = {}
for d in items:
o.update(d)
else:
o = items
item_keys = o.keys()
for field in field_names:
self.assertIn(field, item_keys)
@staticmethod
def parse_show_as_object(raw_output):
"""Return a dict with values parsed from cli output."""
items = TestCase.parse_show(raw_output)
o = {}
for item in items:
o.update(item)
return o
@staticmethod
def parse_show(raw_output):
"""Return list of dicts with item values parsed from cli output."""
items = []
table_ = output_parser.table(raw_output)
for row in table_['values']:
item = {}
item[row[0]] = row[1]
items.append(item)
return items
def parse_listing(self, raw_output):
"""Return list of dicts with basic item parsed from cli output."""
return output_parser.listing(raw_output)
def has_actionplan_succeeded(self, ap_uuid):
return self.parse_show_as_object(
self.watcher('actionplan show %s' % ap_uuid)
)['State'] == 'SUCCEEDED'
@classmethod
def has_audit_created(cls, audit_uuid):
audit = cls.parse_show_as_object(
cls.watcher('audit show %s' % audit_uuid))
if audit['Audit Type'] == 'ONESHOT':
return audit['State'] == 'SUCCEEDED'
else:
return audit['State'] == 'ONGOING'

View File

@@ -0,0 +1,220 @@
# Copyright (c) 2016 Servionica
#
# 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.
from oslo_utils import uuidutils
import functools
from tempest.lib.common.utils import test_utils
from watcherclient.tests.client_functional.v1 import base
class ActionTests(base.TestCase):
"""Functional tests for action."""
dummy_name = 'dummy'
list_fields = ['UUID', 'Parents', 'State', 'Action Plan', 'Action']
detailed_list_fields = list_fields + ['Created At', 'Updated At',
'Deleted At', 'Parameters']
audit_template_name = 'a' + uuidutils.generate_uuid()
audit_uuid = None
@classmethod
def setUpClass(cls):
template_raw_output = cls.watcher(
'audittemplate create %s dummy -s dummy' % cls.audit_template_name)
template_output = cls.parse_show_as_object(template_raw_output)
audit_output = cls.parse_show_as_object(cls.watcher(
'audit create -a %s' % template_output['Name']))
cls.audit_uuid = audit_output['UUID']
audit_created = test_utils.call_until_true(
func=functools.partial(cls.has_audit_created, cls.audit_uuid),
duration=600,
sleep_for=2)
if not audit_created:
raise Exception('Audit has not been succeeded')
@classmethod
def tearDownClass(cls):
# Delete Action Plan and all related actions.
output = cls.parse_show(
cls.watcher('actionplan list --audit %s' % cls.audit_uuid))
action_plan_uuid = list(output[0])[0]
raw_output = cls.watcher('actionplan delete %s' % action_plan_uuid)
cls.assertOutput('', raw_output)
# Delete audit
raw_output = cls.watcher('audit delete %s' % cls.audit_uuid)
cls.assertOutput('', raw_output)
# Delete Template
raw_output = cls.watcher(
'audittemplate delete %s' % cls.audit_template_name)
cls.assertOutput('', raw_output)
def test_action_list(self):
raw_output = self.watcher('action list')
self.assert_table_structure([raw_output], self.list_fields)
def test_action_detailed_list(self):
raw_output = self.watcher('action list --detail')
self.assert_table_structure([raw_output], self.detailed_list_fields)
def test_action_show(self):
action_list = self.parse_show(self.watcher('action list --audit %s'
% self.audit_uuid))
action_uuid = list(action_list[0])[0]
action = self.watcher('action show %s' % action_uuid)
self.assertIn(action_uuid, action)
self.assert_table_structure([action],
self.detailed_list_fields)
class ActionUpdateTests(base.TestCase):
"""Functional tests for action update functionality."""
# Use API version 1.5 for action update tests
api_version = 1.5
dummy_name = 'dummy'
audit_template_name = 'b' + uuidutils.generate_uuid()
audit_uuid = None
action_uuid = None
@classmethod
def setUpClass(cls):
# Create audit template
template_raw_output = cls.watcher(
'audittemplate create %s dummy -s dummy' % cls.audit_template_name)
template_output = cls.parse_show_as_object(template_raw_output)
# Create audit
audit_output = cls.parse_show_as_object(cls.watcher(
'audit create -a %s' % template_output['Name']))
cls.audit_uuid = audit_output['UUID']
# Wait for audit to complete
audit_created = test_utils.call_until_true(
func=functools.partial(cls.has_audit_created, cls.audit_uuid),
duration=600,
sleep_for=2)
if not audit_created:
raise Exception('Audit has not been succeeded')
# Get an action to test updates on
action_list = cls.parse_show(cls.watcher('action list --audit %s'
% cls.audit_uuid))
if action_list:
cls.action_uuid = list(action_list[0])[0]
@classmethod
def tearDownClass(cls):
# Clean up: Delete Action Plan and all related actions
if cls.audit_uuid:
output = cls.parse_show(
cls.watcher('actionplan list --audit %s' % cls.audit_uuid))
if output:
action_plan_uuid = list(output[0])[0]
raw_output = cls.watcher(
'actionplan delete %s' % action_plan_uuid)
cls.assertOutput('', raw_output)
# Delete audit
raw_output = cls.watcher('audit delete %s' % cls.audit_uuid)
cls.assertOutput('', raw_output)
# Delete template
raw_output = cls.watcher(
'audittemplate delete %s' % cls.audit_template_name)
cls.assertOutput('', raw_output)
def test_action_update_with_state_and_reason(self):
"""Test updating action state with reason using API 1.5"""
if not self.action_uuid:
self.skipTest("No actions available for testing")
# Update action state to SKIPPED with reason
raw_output = self.watcher(
'action update --state SKIPPED --reason "Functional test skip" %s'
% self.action_uuid)
# Verify the action was updated
action = self.parse_show_as_object(
self.watcher('action show %s' % self.action_uuid))
self.assertEqual('SKIPPED', action['State'])
self.assertEqual('Action skipped by user. Reason: Functional test '
'skip', action['Status Message'])
# Verify output contains the action UUID
self.assertIn(self.action_uuid, raw_output)
def test_action_update_with_state_only(self):
"""Test updating action state without reason"""
if not self.action_uuid:
self.skipTest("No actions available for testing")
# Update action state to SKIPPED without reason
raw_output = self.watcher(
'action update --state SKIPPED %s' % self.action_uuid)
# Verify the action was updated
action = self.parse_show_as_object(
self.watcher('action show %s' % self.action_uuid))
self.assertEqual('SKIPPED', action['State'])
# Verify output contains the action UUID
self.assertIn(self.action_uuid, raw_output)
def test_action_update_missing_state_fails(self):
"""Test that action update fails when no state is provided"""
if not self.action_uuid:
self.skipTest("No actions available for testing")
# This should fail because --state is required
raw_output = self.watcher(
'action update %s' % self.action_uuid, fail_ok=True)
# Should contain error message about missing state
self.assertIn(
'At least one field update is required for this operation',
raw_output)
def test_action_update_nonexistent_action_fails(self):
"""Test that action update fails for non-existent action"""
fake_uuid = uuidutils.generate_uuid()
# This should fail because the action doesn't exist
raw_output = self.watcher(
'action update --state SKIPPED %s' % fake_uuid, fail_ok=True)
# Should contain error message about action not found
self.assertIn('404', raw_output)
class ActionUpdateApiVersionTests(base.TestCase):
"""Test action update functionality with different API versions."""
# Use API version 1.0 to test version checking
api_version = 1.0
def test_action_update_unsupported_api_version(self):
"""Test that action update fails with API version < 1.5"""
fake_uuid = uuidutils.generate_uuid()
# This should fail because API version 1.0 doesn't support updates
raw_output = self.watcher(
'action update --state SKIPPED %s' % fake_uuid, fail_ok=True)
# Should contain error message about unsupported API version
self.assertIn('not supported in API version', raw_output)
self.assertIn('Minimum required version is 1.5', raw_output)

View File

@@ -0,0 +1,97 @@
# Copyright (c) 2016 Servionica
#
# 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.
from oslo_utils import uuidutils
import functools
from tempest.lib.common.utils import test_utils
from watcherclient.tests.client_functional.v1 import base
class ActionPlanTests(base.TestCase):
"""Functional tests for action plan."""
dummy_name = 'dummy'
list_fields = ['UUID', 'Audit', 'State', 'Updated At', 'Global efficacy']
detailed_list_fields = list_fields + ['Created At', 'Deleted At',
'Strategy', 'Efficacy indicators',
'Hostname']
audit_template_name = 'a' + uuidutils.generate_uuid()
audit_uuid = None
@classmethod
def setUpClass(cls):
template_raw_output = cls.watcher(
'audittemplate create %s dummy -s dummy' % cls.audit_template_name)
template_output = cls.parse_show_as_object(template_raw_output)
audit_raw_output = cls.watcher('audit create -a %s'
% template_output['Name'])
audit_output = cls.parse_show_as_object(audit_raw_output)
cls.audit_uuid = audit_output['UUID']
audit_created = test_utils.call_until_true(
func=functools.partial(cls.has_audit_created, cls.audit_uuid),
duration=600,
sleep_for=2)
if not audit_created:
raise Exception('Audit has not been succeeded')
@classmethod
def tearDownClass(cls):
# Delete action plan
output = cls.parse_show(
cls.watcher('actionplan list --audit %s' % cls.audit_uuid))
action_plan_uuid = list(output[0])[0]
raw_output = cls.watcher('actionplan delete %s' % action_plan_uuid)
cls.assertOutput('', raw_output)
# Delete audit
raw_output = cls.watcher('audit delete %s' % cls.audit_uuid)
cls.assertOutput('', raw_output)
# Delete Template
raw_output = cls.watcher(
'audittemplate delete %s' % cls.audit_template_name)
cls.assertOutput('', raw_output)
def test_action_plan_list(self):
raw_output = self.watcher('actionplan list')
self.assert_table_structure([raw_output], self.list_fields)
def test_action_plan_detailed_list(self):
raw_output = self.watcher('actionplan list --detail')
self.assert_table_structure([raw_output], self.detailed_list_fields)
def test_action_plan_show(self):
action_plan_list = self.parse_show(self.watcher('actionplan list'))
action_plan_uuid = list(action_plan_list[0])[0]
actionplan = self.watcher('actionplan show %s' % action_plan_uuid)
self.assertIn(action_plan_uuid, actionplan)
self.assert_table_structure([actionplan],
self.detailed_list_fields)
def test_action_plan_start(self):
output = self.parse_show(self.watcher('actionplan list --audit %s'
% self.audit_uuid))
action_plan_uuid = list(output[0])[0]
self.watcher('actionplan start %s' % action_plan_uuid)
raw_output = self.watcher('actionplan show %s' % action_plan_uuid)
self.assert_table_structure([raw_output], self.detailed_list_fields)
self.assertTrue(test_utils.call_until_true(
func=functools.partial(
self.has_actionplan_succeeded, action_plan_uuid),
duration=600,
sleep_for=2
))

View File

@@ -0,0 +1,212 @@
# Copyright (c) 2016 Servionica
#
# 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.
from datetime import datetime
from dateutil import tz
import functools
from oslo_utils import uuidutils
from tempest.lib.common.utils import test_utils
from watcherclient.tests.client_functional.v1 import base
class AuditTests(base.TestCase):
"""Functional tests for audit."""
dummy_name = 'dummy'
list_fields = ['UUID', 'Name', 'Audit Type', 'State', 'Goal', 'Strategy']
detailed_list_fields = list_fields + ['Created At', 'Updated At',
'Deleted At', 'Parameters',
'Interval', 'Audit Scope',
'Next Run Time', 'Hostname']
audit_template_name = 'a' + uuidutils.generate_uuid()
audit_uuid = None
@classmethod
def setUpClass(cls):
raw_output = cls.watcher('audittemplate create %s dummy -s dummy'
% cls.audit_template_name)
template_output = cls.parse_show_as_object(raw_output)
audit_raw_output = cls.watcher(
'audit create -a %s' % template_output['Name'])
audit_output = cls.parse_show_as_object(audit_raw_output)
cls.audit_uuid = audit_output['UUID']
audit_created = test_utils.call_until_true(
func=functools.partial(cls.has_audit_created, cls.audit_uuid),
duration=600,
sleep_for=2)
if not audit_created:
raise Exception('Audit has not been succeeded')
@classmethod
def tearDownClass(cls):
output = cls.parse_show(
cls.watcher('actionplan list --audit %s' % cls.audit_uuid))
action_plan_uuid = list(output[0])[0]
cls.watcher('actionplan delete %s' % action_plan_uuid)
cls.watcher('audit delete %s' % cls.audit_uuid)
cls.watcher('audittemplate delete %s' % cls.audit_template_name)
def test_audit_list(self):
raw_output = self.watcher('audit list')
self.assert_table_structure([raw_output], self.list_fields)
def test_audit_detailed_list(self):
raw_output = self.watcher('audit list --detail')
self.assert_table_structure([raw_output], self.detailed_list_fields)
def test_audit_show(self):
audit = self.watcher('audit show ' + self.audit_uuid)
self.assertIn(self.audit_uuid, audit)
self.assert_table_structure([audit], self.detailed_list_fields)
def test_audit_update(self):
audit_raw_output = self.watcher('audit update %s add interval=2'
% self.audit_uuid)
audit_output = self.parse_show_as_object(audit_raw_output)
assert int(audit_output['Interval']) == 2
class AuditTestsV11(AuditTests):
"""This class tests v1.1 of Watcher API"""
api_version = 1.1
detailed_list_fields = AuditTests.list_fields + [
'Created At', 'Updated At', 'Deleted At', 'Parameters', 'Interval',
'Audit Scope', 'Next Run Time', 'Hostname', 'Start Time', 'End Time']
def test_audit_detailed_list(self):
raw_output = self.watcher('audit list --detail')
self.assert_table_structure([raw_output], self.detailed_list_fields)
def test_audit_show(self):
audit = self.watcher('audit show ' + self.audit_uuid)
self.assertIn(self.audit_uuid, audit)
self.assert_table_structure([audit], self.detailed_list_fields)
def test_audit_update(self):
local_time = datetime.now(tz.tzlocal())
local_time_str = local_time.strftime("%Y-%m-%dT%H:%M:%S")
utc_time = (local_time - local_time.utcoffset())
utc_time_str = utc_time.strftime("%Y-%m-%dT%H:%M:%S")
audit_raw_output = self.watcher(
'audit update {0} replace end_time="{1}"'.format(self.audit_uuid,
local_time_str))
audit_output = self.parse_show_as_object(audit_raw_output)
assert audit_output['End Time'] == utc_time_str
class AuditTestsV12(AuditTestsV11):
"""This class tests v1.2 of Watcher API"""
api_version = 1.2
@classmethod
def setUpClass(cls):
raw_output = cls.watcher('audittemplate create %s dummy -s dummy'
% cls.audit_template_name)
template_output = cls.parse_show_as_object(raw_output)
audit_raw_output = cls.watcher(
'audit create --force -a %s' % template_output['Name'])
audit_output = cls.parse_show_as_object(audit_raw_output)
cls.audit_uuid = audit_output['UUID']
audit_created = test_utils.call_until_true(
func=functools.partial(cls.has_audit_created, cls.audit_uuid),
duration=600,
sleep_for=2)
if not audit_created:
raise Exception('Audit has not been succeeded')
class AuditActiveTests(base.TestCase):
list_fields = ['UUID', 'Name', 'Audit Type', 'State', 'Goal', 'Strategy']
detailed_list_fields = list_fields + ['Created At', 'Updated At',
'Deleted At', 'Parameters',
'Interval', 'Audit Scope']
audit_template_name = 'a' + uuidutils.generate_uuid()
@classmethod
def setUpClass(cls):
cls.watcher('audittemplate create %s dummy -s dummy'
% cls.audit_template_name)
@classmethod
def tearDownClass(cls):
cls.watcher('audittemplate delete %s' % cls.audit_template_name)
def _create_audit(self):
return self.parse_show_as_object(
self.watcher('audit create -a %s'
% self.audit_template_name))['UUID']
def _delete_audit(self, audit_uuid):
self.assertTrue(test_utils.call_until_true(
func=functools.partial(
self.has_audit_created, audit_uuid),
duration=600,
sleep_for=2
))
output = self.parse_show(
self.watcher('actionplan list --audit %s' % audit_uuid))
action_plan_uuid = list(output[0])[0]
self.watcher('actionplan delete %s' % action_plan_uuid)
self.watcher('audit delete %s' % audit_uuid)
def test_create_oneshot_audit(self):
audit = self.watcher('audit create -a %s' % self.audit_template_name)
audit_uuid = self.parse_show_as_object(audit)['UUID']
self.assert_table_structure([audit], self.detailed_list_fields)
self._delete_audit(audit_uuid)
def test_delete_oneshot_audit(self):
audit_uuid = self._create_audit()
self.assertTrue(test_utils.call_until_true(
func=functools.partial(
self.has_audit_created, audit_uuid),
duration=600,
sleep_for=2
))
raw_output = self.watcher('audit delete %s' % audit_uuid)
self.assertOutput('', raw_output)
output = self.parse_show(
self.watcher('actionplan list --audit %s' % audit_uuid))
action_plan_uuid = list(output[0])[0]
self.watcher('actionplan delete %s' % action_plan_uuid)
def test_continuous_audit(self):
audit = self.watcher('audit create -a %s -t CONTINUOUS -i 600'
% self.audit_template_name)
audit_uuid = self.parse_show_as_object(audit)['UUID']
self.assert_table_structure([audit], self.detailed_list_fields)
self.assertTrue(test_utils.call_until_true(
func=functools.partial(
self.has_audit_created, audit_uuid),
duration=600,
sleep_for=2
))
audit_state = self.parse_show_as_object(
self.watcher('audit show %s' % audit_uuid))['State']
if audit_state == 'ONGOING':
self.watcher('audit update %s replace state=CANCELLED'
% audit_uuid)
raw_output = self.watcher('audit delete %s' % audit_uuid)
self.assertOutput('', raw_output)
outputs = self.parse_listing(
self.watcher('actionplan list --audit %s' % audit_uuid))
for actionplan in outputs:
self.watcher('actionplan delete %s' % actionplan['UUID'])

View File

@@ -0,0 +1,89 @@
# Copyright (c) 2016 Servionica
#
# 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.
from oslo_utils import uuidutils
from watcherclient.tests.client_functional.v1 import base
class AuditTemplateTests(base.TestCase):
"""Functional tests for audit template."""
dummy_name = 'dummy'
list_fields = ['UUID', 'Name', 'Goal', 'Strategy']
detailed_list_fields = list_fields + ['Created At', 'Updated At',
'Deleted At', 'Description',
'Audit Scope']
audit_template_name = 'a' + uuidutils.generate_uuid()
@classmethod
def setUpClass(cls):
cls.watcher('audittemplate create %s dummy -s dummy'
% cls.audit_template_name)
@classmethod
def tearDownClass(cls):
cls.watcher('audittemplate delete %s' % cls.audit_template_name)
def test_audit_template_list(self):
raw_output = self.watcher('audittemplate list')
self.assert_table_structure([raw_output], self.list_fields)
def test_audit_template_detailed_list(self):
raw_output = self.watcher('audittemplate list --detail')
self.assert_table_structure([raw_output], self.detailed_list_fields)
def test_audit_template_show(self):
audit_template = self.watcher(
'audittemplate show %s' % self.audit_template_name)
self.assertIn(self.audit_template_name, audit_template)
self.assert_table_structure([audit_template],
self.detailed_list_fields)
def test_audit_template_update(self):
raw_output = self.watcher('audittemplate update %s replace '
'description="Updated Desc"'
% self.audit_template_name)
audit_template_output = self.parse_show_as_object(raw_output)
assert audit_template_output['Description'] == 'Updated Desc'
class AuditTemplateActiveTests(base.TestCase):
audit_template_name = 'b' + uuidutils.generate_uuid()
list_fields = ['UUID', 'Name', 'Goal', 'Strategy']
detailed_list_fields = list_fields + ['Created At', 'Updated At',
'Deleted At', 'Description',
'Audit Scope']
def _create_audit_template(self):
self.watcher('audittemplate create %s dummy -s dummy '
'-d "Test Audit Template"' % self.audit_template_name)
def _delete_audit_template(self):
self.watcher('audittemplate delete %s' % self.audit_template_name)
def test_create_audit_template(self):
raw_output = self.watcher('audittemplate create %s dummy '
'-s dummy -d "Test Audit Template"'
% self.audit_template_name)
self.assert_table_structure([raw_output], self.detailed_list_fields)
self._delete_audit_template()
def test_delete_audit_template(self):
self._create_audit_template()
raw_output = self.watcher('audittemplate delete %s'
% self.audit_template_name)
self.assertOutput('', raw_output)

View File

@@ -0,0 +1,41 @@
# Copyright (c) 2016 Servionica
#
# 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.
from watcherclient.tests.client_functional.v1 import base
class GoalTests(base.TestCase):
"""Functional tests for goal."""
dummy_name = 'dummy'
list_fields = ['UUID', 'Name', 'Display name']
def test_goal_list(self):
raw_output = self.watcher('goal list')
self.assertIn(self.dummy_name, raw_output)
self.assert_table_structure([raw_output], self.list_fields)
def test_goal_detailed_list(self):
raw_output = self.watcher('goal list --detail')
self.assertIn(self.dummy_name, raw_output)
self.assert_table_structure(
[raw_output], self.list_fields + ['Efficacy specification'])
def test_goal_show(self):
raw_output = self.watcher('goal show %s' % self.dummy_name)
self.assertIn(self.dummy_name, raw_output)
self.assert_table_structure(
[raw_output], self.list_fields + ['Efficacy specification'])
self.assertNotIn('server_consolidation', raw_output)

View File

@@ -0,0 +1,40 @@
# Copyright (c) 2016 Servionica
#
# 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.
from watcherclient.tests.client_functional.v1 import base
class ScoringEngineTests(base.TestCase):
"""Functional tests for scoring engine."""
dummy_name = 'dummy_scorer'
list_fields = ['UUID', 'Name', 'Description']
detailed_list_fields = list_fields + ['Metainfo']
def test_scoringengine_list(self):
raw_output = self.watcher('scoringengine list')
self.assertIn(self.dummy_name, raw_output)
self.assert_table_structure([raw_output], self.list_fields)
def test_scoringengine_detailed_list(self):
raw_output = self.watcher('scoringengine list --detail')
self.assertIn(self.dummy_name, raw_output)
self.assert_table_structure([raw_output], self.detailed_list_fields)
def test_scoringengine_show(self):
raw_output = self.watcher('scoringengine show %s' % self.dummy_name)
self.assertIn(self.dummy_name, raw_output)
self.assert_table_structure([raw_output], self.detailed_list_fields)
self.assertNotIn('dummy_avg_scorer', raw_output)

View File

@@ -0,0 +1,47 @@
# Copyright (c) 2016 Servionica
#
# 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.
from watcherclient.tests.client_functional.v1 import base
class ServiceTests(base.TestCase):
"""Functional tests for service."""
decision_engine_name = 'watcher-decision-engine'
applier_name = 'watcher-applier'
list_fields = ['ID', 'Name', 'Host', 'Status']
def test_service_list(self):
raw_output = self.watcher('service list')
self.assertIn(self.decision_engine_name, raw_output)
self.assertIn(self.applier_name, raw_output)
self.assert_table_structure([raw_output], self.list_fields)
def test_service_detailed_list(self):
raw_output = self.watcher('service list --detail')
self.assertIn(self.decision_engine_name, raw_output)
self.assertIn(self.applier_name, raw_output)
self.assert_table_structure([raw_output],
self.list_fields + ['Last seen up'])
def test_service_show(self):
# TODO(alexchadin): this method should be refactored since Watcher will
# get HA support soon.
raw_output = self.watcher('service show %s'
% self.decision_engine_name)
self.assertIn(self.decision_engine_name, raw_output)
self.assert_table_structure([raw_output],
self.list_fields + ['Last seen up'])
self.assertNotIn(self.applier_name, raw_output)

View File

@@ -0,0 +1,48 @@
# Copyright (c) 2016 Servionica
#
# 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.
from watcherclient.tests.client_functional.v1 import base
class StrategyTests(base.TestCase):
"""Functional tests for strategy."""
dummy_name = 'dummy'
basic_strategy = 'basic'
list_fields = ['UUID', 'Name', 'Display name', 'Goal']
state_fields = ['Datasource', 'Metrics', 'CDM', 'Name']
def test_strategy_list(self):
raw_output = self.watcher('strategy list')
self.assertIn(self.dummy_name, raw_output)
self.assert_table_structure([raw_output], self.list_fields)
def test_strategy_detailed_list(self):
raw_output = self.watcher('strategy list --detail')
self.assertIn(self.dummy_name, raw_output)
self.assert_table_structure([raw_output],
self.list_fields + ['Parameters spec'])
def test_strategy_show(self):
raw_output = self.watcher('strategy show %s' % self.dummy_name)
self.assertIn(self.dummy_name, raw_output)
self.assert_table_structure([raw_output],
self.list_fields + ['Parameters spec'])
self.assertNotIn('basic', raw_output)
def test_strategy_state(self):
raw_output = self.watcher('strategy state %s' % self.basic_strategy)
self.assertIn(self.basic_strategy, raw_output)
self.assert_table_structure([raw_output], self.state_fields)

View File

@@ -148,3 +148,22 @@ class GetAPIVersionTestCase(utils.BaseTestCase):
self.assertEqual(mock_apiversion.return_value,
api_versioning.get_api_version(version))
mock_apiversion.assert_called_once_with(version)
class APIVersionFunctionsTestCase(utils.BaseTestCase):
def test_action_update_supported_true(self):
# Test versions >= 1.5 support action update
self.assertTrue(api_versioning.action_update_supported("1.5"))
self.assertTrue(api_versioning.action_update_supported("1.6"))
self.assertTrue(api_versioning.action_update_supported("2.0"))
def test_action_update_supported_false(self):
# Test versions < 1.5 do not support action update
self.assertFalse(api_versioning.action_update_supported("1.0"))
self.assertFalse(api_versioning.action_update_supported("1.1"))
self.assertFalse(api_versioning.action_update_supported("1.4"))
def test_action_update_supported_edge_case(self):
# Test exact boundary
self.assertTrue(api_versioning.action_update_supported("1.5"))
self.assertFalse(api_versioning.action_update_supported("1.4"))

View File

@@ -358,3 +358,8 @@ class ClientTest(utils.BaseTestCase):
client = httpclient.HTTPClient(endpoint)
conn_url = client._make_connection_url(url)
self.assertEqual(expected_url, conn_url)
def test_port_ends_with_one(self):
endpoint = "http://localhost:8081/"
http_client = httpclient.HTTPClient(endpoint)
self.assertEqual(endpoint, http_client._make_connection_url(""))

View File

@@ -92,6 +92,10 @@ fake_responses = {
{},
None,
),
'PATCH': (
{},
ACTION1,
),
},
'/v1/actions/detail?action_plan_uuid=%s' % ACTION1['action_plan']:
{
@@ -264,3 +268,12 @@ class ActionManagerTest(testtools.TestCase):
self.assertEqual(ACTION1['uuid'], action.uuid)
self.assertEqual(ACTION1['action_plan'], action.action_plan)
self.assertEqual(ACTION1['next'], action.next)
def test_actions_update(self):
patch = [{'op': 'replace', 'path': '/state', 'value': 'SKIPPED'}]
action = self.mgr.update(ACTION1['uuid'], patch)
expect = [
('PATCH', '/v1/actions/%s' % ACTION1['uuid'], {}, patch),
]
self.assertEqual(expect, self.api.calls)
self.assertEqual(ACTION1['uuid'], action.uuid)

View File

@@ -79,8 +79,9 @@ class ActionPlanShellTest(base.CommandTestCase):
FIELD_LABELS = resource_fields.ACTION_PLAN_FIELD_LABELS
GLOBAL_EFFICACY_FIELDS = resource_fields.GLOBAL_EFFICACY_FIELDS
def setUp(self):
super(self.__class__, self).setUp()
def setUp(self, os_infra_optim_api_version='1.0'):
super(ActionPlanShellTest, self).setUp(
os_infra_optim_api_version=os_infra_optim_api_version)
p_audit_manager = mock.patch.object(resource, 'AuditManager')
p_audit_template_manager = mock.patch.object(
@@ -336,3 +337,14 @@ class ActionPlanShellTest(base.CommandTestCase):
self.assertEqual(1, exit_code)
self.assertEqual('', result)
class ActionPlanShellTest15(ActionPlanShellTest):
def setUp(self):
super(ActionPlanShellTest15, self).setUp(
os_infra_optim_api_version='1.5')
v15 = dict(status_message=None)
for action_plan in (ACTION_PLAN_1, ACTION_PLAN_2):
action_plan.update(v15)
self.FIELDS.extend(['status_message'])
self.FIELD_LABELS.extend(['Status Message'])

View File

@@ -15,6 +15,7 @@
import datetime
import io
import unittest
from unittest import mock
from watcherclient import exceptions
@@ -78,8 +79,9 @@ class ActionShellTest(base.CommandTestCase):
FIELDS = resource_fields.ACTION_FIELDS
FIELD_LABELS = resource_fields.ACTION_FIELD_LABELS
def setUp(self):
super(self.__class__, self).setUp()
def setUp(self, os_infra_optim_api_version='1.0'):
super(ActionShellTest, self).setUp(
os_infra_optim_api_version=os_infra_optim_api_version)
p_action_manager = mock.patch.object(resource, 'ActionManager')
p_action_plan_manager = mock.patch.object(
@@ -176,3 +178,106 @@ class ActionShellTest(base.CommandTestCase):
self.assertEqual(1, exit_code)
self.assertEqual('', result)
def test_do_action_update_unsupported_version(self):
exit_code, result = self.run_cmd(
'action update --state SKIPPED '
'770ef053-ecb3-48b0-85b5-d55a2dbc6588',
formatting=None)
self.assertEqual(1, exit_code)
self.assertEqual('', result)
class ActionShellTest15(ActionShellTest):
def setUp(self):
super(ActionShellTest15, self).setUp(os_infra_optim_api_version='1.5')
v15 = dict(status_message=None)
for action in (ACTION_1, ACTION_2, ACTION_3):
action.update(v15)
self.FIELDS.extend(['status_message'])
self.FIELD_LABELS.extend(['Status Message'])
def test_do_action_update_with_state_only(self):
action = resource.Action(mock.Mock(), ACTION_1)
self.m_action_mgr.update.return_value = action
exit_code, result = self.run_cmd(
'action update --state SKIPPED '
'770ef053-ecb3-48b0-85b5-d55a2dbc6588')
self.assertEqual(0, exit_code)
self.assertEqual(
self.resource_as_dict(action, self.FIELDS, self.FIELD_LABELS),
result)
expected_patch = [
{'op': 'replace', 'path': '/state', 'value': 'SKIPPED'}
]
self.m_action_mgr.update.assert_called_once_with(
'770ef053-ecb3-48b0-85b5-d55a2dbc6588', expected_patch)
def test_do_action_update_with_state_and_reason(self):
action = resource.Action(mock.Mock(), ACTION_1)
self.m_action_mgr.update.return_value = action
exit_code, result = self.run_cmd(
'action update --state SKIPPED --reason "Manual skip" '
'770ef053-ecb3-48b0-85b5-d55a2dbc6588')
self.assertEqual(0, exit_code)
self.assertEqual(
self.resource_as_dict(action, self.FIELDS, self.FIELD_LABELS),
result)
expected_patch = [
{'op': 'replace', 'path': '/state', 'value': 'SKIPPED'},
{'op': 'replace', 'path': '/status_message',
'value': 'Manual skip'}
]
self.m_action_mgr.update.assert_called_once_with(
'770ef053-ecb3-48b0-85b5-d55a2dbc6588', expected_patch)
def test_do_action_update_with_reason_only(self):
action = resource.Action(mock.Mock(), ACTION_1)
self.m_action_mgr.update.return_value = action
exit_code, result = self.run_cmd(
'action update --reason "Manual skip" '
'770ef053-ecb3-48b0-85b5-d55a2dbc6588')
self.assertEqual(0, exit_code)
self.assertEqual(
self.resource_as_dict(action, self.FIELDS, self.FIELD_LABELS),
result)
expected_patch = [
{'op': 'replace', 'path': '/status_message',
'value': 'Manual skip'}
]
self.m_action_mgr.update.assert_called_once_with(
'770ef053-ecb3-48b0-85b5-d55a2dbc6588', expected_patch)
def test_do_action_update_no_fields_to_update(self):
exit_code, result = self.run_cmd(
'action update 770ef053-ecb3-48b0-85b5-d55a2dbc6588',
formatting=None)
self.assertEqual(1, exit_code)
self.assertEqual('', result)
def test_do_action_update_action_not_found(self):
self.m_action_mgr.update.side_effect = exceptions.HTTPNotFound
exit_code, result = self.run_cmd(
'action update --state SKIPPED not_found_uuid',
formatting=None)
self.assertEqual(1, exit_code)
self.assertEqual('', result)
@unittest.skip("Action update is supported in API version 1.5")
def test_do_action_update_unsupported_version(self):
pass

15
watcherclient/tests/unit/v1/test_audit_shell.py Executable file → Normal file
View File

@@ -494,8 +494,9 @@ class AuditShellTestv11(AuditShellTest):
class AuditShellTestv12(AuditShellTest):
def setUp(self):
super(AuditShellTestv12, self).setUp(os_infra_optim_api_version='1.2')
def setUp(self, os_infra_optim_api_version='1.2'):
super(AuditShellTestv12, self).setUp(
os_infra_optim_api_version=os_infra_optim_api_version)
v11 = dict(start_time=None, end_time=None)
v12 = dict(force=False)
for audit in (self.AUDIT_1, self.AUDIT_2, self.AUDIT_3):
@@ -697,3 +698,13 @@ class AuditShellTestv12(AuditShellTest):
name='my_audit',
force=False
)
class AuditShellTestv15(AuditShellTestv12):
def setUp(self):
super(AuditShellTestv15, self).setUp(os_infra_optim_api_version='1.5')
v15 = dict(status_message=None)
for audit in (self.AUDIT_1, self.AUDIT_2, self.AUDIT_3):
audit.update(v15)
self.FIELDS.extend(['status_message'])
self.FIELD_LABELS.extend(['Status Message'])

View File

@@ -83,3 +83,6 @@ class ActionManager(base.Manager):
return self._list(self._path(action_id))[0]
except IndexError:
return None
def update(self, action_id, patch):
return self._update(self._path(action_id), patch)

View File

@@ -13,6 +13,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import copy
import io
from cliff.formatters import yaml_format
@@ -20,12 +21,23 @@ from osc_lib import utils
from oslo_utils import uuidutils
from watcherclient._i18n import _
from watcherclient.common import api_versioning
from watcherclient.common import command
from watcherclient.common import utils as common_utils
from watcherclient import exceptions
from watcherclient.v1 import resource_fields as res_fields
def drop_unsupported_field(app_args, fields, field_labels):
fields = copy.copy(fields)
field_labels = copy.copy(field_labels)
api_ver = app_args.os_infra_optim_api_version
if not api_versioning.action_update_supported(api_ver):
fields.remove('status_message')
field_labels.remove('Status Message')
return fields, field_labels
def format_global_efficacy(global_efficacy):
formatted_global_eff = {}
for eff in global_efficacy:
@@ -101,6 +113,8 @@ class ShowActionPlan(command.ShowOne):
columns = res_fields.ACTION_PLAN_FIELDS
column_headers = res_fields.ACTION_PLAN_FIELD_LABELS
columns, column_headers = drop_unsupported_field(
self.app_args, columns, column_headers)
return column_headers, utils.get_item_properties(action_plan, columns)
@@ -178,6 +192,8 @@ class ListActionPlan(command.Lister):
if parsed_args.detail:
fields = res_fields.ACTION_PLAN_FIELDS
field_labels = res_fields.ACTION_PLAN_FIELD_LABELS
fields, field_labels = drop_unsupported_field(
self.app_args, fields, field_labels)
else:
fields = res_fields.ACTION_PLAN_SHORT_LIST_FIELDS
field_labels = res_fields.ACTION_PLAN_SHORT_LIST_FIELD_LABELS
@@ -239,6 +255,8 @@ class UpdateActionPlan(command.ShowOne):
columns = res_fields.ACTION_PLAN_FIELDS
column_headers = res_fields.ACTION_PLAN_FIELD_LABELS
columns, column_headers = drop_unsupported_field(
self.app_args, columns, column_headers)
return column_headers, utils.get_item_properties(action_plan, columns)
@@ -265,6 +283,8 @@ class StartActionPlan(command.ShowOne):
columns = res_fields.ACTION_PLAN_FIELDS
column_headers = res_fields.ACTION_PLAN_FIELD_LABELS
columns, column_headers = drop_unsupported_field(
self.app_args, columns, column_headers)
return column_headers, utils.get_item_properties(action_plan, columns)
@@ -314,5 +334,7 @@ class CancelActionPlan(command.ShowOne):
columns = res_fields.ACTION_PLAN_FIELDS
column_headers = res_fields.ACTION_PLAN_FIELD_LABELS
columns, column_headers = drop_unsupported_field(
self.app_args, columns, column_headers)
return column_headers, utils.get_item_properties(action_plan, columns)

View File

@@ -13,15 +13,29 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import copy
from osc_lib import utils
from oslo_utils import uuidutils
from watcherclient._i18n import _
from watcherclient.common import api_versioning
from watcherclient.common import command
from watcherclient.common import utils as common_utils
from watcherclient import exceptions
from watcherclient.v1 import resource_fields as res_fields
def drop_unsupported_field(app_args, fields, field_labels):
fields = copy.copy(fields)
field_labels = copy.copy(field_labels)
api_ver = app_args.os_infra_optim_api_version
if not api_versioning.action_update_supported(api_ver):
fields.remove('status_message')
field_labels.remove('Status Message')
return fields, field_labels
class ShowAction(command.ShowOne):
"""Show detailed information about a given action."""
@@ -44,6 +58,8 @@ class ShowAction(command.ShowOne):
columns = res_fields.ACTION_FIELDS
column_headers = res_fields.ACTION_FIELD_LABELS
columns, column_headers = drop_unsupported_field(
self.app_args, columns, column_headers)
return column_headers, utils.get_item_properties(action, columns)
@@ -104,6 +120,8 @@ class ListAction(command.Lister):
if parsed_args.detail:
fields = res_fields.ACTION_FIELDS
field_labels = res_fields.ACTION_FIELD_LABELS
fields, field_labels = drop_unsupported_field(
self.app_args, fields, field_labels)
else:
fields = res_fields.ACTION_SHORT_LIST_FIELDS
field_labels = res_fields.ACTION_SHORT_LIST_FIELD_LABELS
@@ -119,3 +137,67 @@ class ListAction(command.Lister):
return (field_labels,
(utils.get_item_properties(item, fields) for item in data))
class UpdateAction(command.ShowOne):
"""Update action command."""
def get_parser(self, prog_name):
parser = super(UpdateAction, self).get_parser(prog_name)
parser.add_argument(
'action',
metavar='<action>',
help=_('UUID of the action'))
parser.add_argument(
'--state',
metavar='<state>',
help=_('New state for the action (e.g., SKIPPED)'))
parser.add_argument(
'--reason',
metavar='<reason>',
help=_('Reason for the state change'))
return parser
def take_action(self, parsed_args):
client = getattr(self.app.client_manager, "infra-optim")
# Check if action update is supported in the requested API version
api_ver = self.app_args.os_infra_optim_api_version
if not api_versioning.action_update_supported(api_ver):
raise exceptions.CommandError(
_("Action update is not supported in API version %s. "
"Minimum required version is 1.5.") % api_ver)
if not parsed_args.state and not parsed_args.reason:
raise exceptions.CommandError(
_("At least one field update is required for this operation"))
if not uuidutils.is_uuid_like(parsed_args.action):
raise exceptions.ValidationError()
patch = []
if parsed_args.state:
patch.append({
'op': 'replace',
'path': '/state',
'value': parsed_args.state
})
if parsed_args.reason:
patch.append({
'op': 'replace',
'path': '/status_message',
'value': parsed_args.reason
})
try:
action = client.action.update(parsed_args.action, patch)
except exceptions.HTTPNotFound as exc:
raise exceptions.CommandError(str(exc))
columns = res_fields.ACTION_FIELDS
column_headers = res_fields.ACTION_FIELD_LABELS
columns, column_headers = drop_unsupported_field(
self.app_args, columns, column_headers)
return column_headers, utils.get_item_properties(action, columns)

View File

@@ -38,6 +38,9 @@ def drop_unsupported_field(app_args, fields, field_labels):
if not api_versioning.launch_audit_forced(api_ver):
fields.remove('force')
field_labels.remove('Force')
if not api_versioning.action_update_supported(api_ver):
fields.remove('status_message')
field_labels.remove('Status Message')
return fields, field_labels

14
watcherclient/v1/resource_fields.py Executable file → Normal file
View File

@@ -33,13 +33,14 @@ AUDIT_TEMPLATE_SHORT_LIST_FIELD_LABELS = ['UUID', 'Name', 'Goal', 'Strategy']
AUDIT_FIELDS = ['uuid', 'name', 'created_at', 'updated_at', 'deleted_at',
'state', 'audit_type', 'parameters', 'interval', 'goal_name',
'strategy_name', 'scope', 'auto_trigger', 'next_run_time',
'hostname', 'start_time', 'end_time', 'force']
'hostname', 'start_time', 'end_time', 'force',
'status_message']
AUDIT_FIELD_LABELS = ['UUID', 'Name', 'Created At', 'Updated At', 'Deleted At',
'State', 'Audit Type', 'Parameters', 'Interval', 'Goal',
'Strategy', 'Audit Scope', 'Auto Trigger',
'Next Run Time', 'Hostname', 'Start Time', 'End Time',
'Force']
'Force', 'Status Message']
AUDIT_SHORT_LIST_FIELDS = ['uuid', 'name', 'audit_type',
'state', 'goal_name', 'strategy_name',
@@ -51,12 +52,13 @@ AUDIT_SHORT_LIST_FIELD_LABELS = ['UUID', 'Name', 'Audit Type', 'State', 'Goal',
# Action Plan
ACTION_PLAN_FIELDS = ['uuid', 'created_at', 'updated_at', 'deleted_at',
'audit_uuid', 'strategy_name', 'state',
'efficacy_indicators', 'global_efficacy', 'hostname']
'efficacy_indicators', 'global_efficacy', 'hostname',
'status_message']
ACTION_PLAN_FIELD_LABELS = ['UUID', 'Created At', 'Updated At', 'Deleted At',
'Audit', 'Strategy', 'State',
'Efficacy indicators', 'Global efficacy',
'Hostname']
'Hostname', 'Status Message']
ACTION_PLAN_SHORT_LIST_FIELDS = ['uuid', 'audit_uuid', 'state',
'updated_at', 'global_efficacy']
@@ -69,11 +71,11 @@ GLOBAL_EFFICACY_FIELDS = ['value', 'unit', 'name', 'description']
# Action
ACTION_FIELDS = ['uuid', 'created_at', 'updated_at', 'deleted_at', 'parents',
'state', 'action_plan_uuid', 'action_type',
'input_parameters', 'description']
'input_parameters', 'description', 'status_message']
ACTION_FIELD_LABELS = ['UUID', 'Created At', 'Updated At', 'Deleted At',
'Parents', 'State', 'Action Plan', 'Action',
'Parameters', 'Description']
'Parameters', 'Description', 'Status Message']
ACTION_SHORT_LIST_FIELDS = ['uuid', 'parents',
'state', 'action_plan_uuid', 'action_type']

View File

@@ -71,7 +71,7 @@ class StateStrategy(command.Lister):
def _format_spec(self, requirements):
for req in requirements:
if type(req.state) == list:
if isinstance(req.state, list):
req.state = jsonutils.dumps(req.state, indent=2)
return requirements